fix(client): align sidebar spacing and enlarge row action targets
This commit is contained in:
1 parent
d6d51be4cb
commit
86fb01a613
28 files changed
+439
-91
No files matched your search
@@ -6,9 +6,9 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
@@ -37,6 +37,7 @@ interface ListMetrics {
|
||||
band: number
|
||||
scrollbarEdgeOffset: number
|
||||
rowEdgeInset: number
|
||||
rowPaintInset: number
|
||||
clientRight: number
|
||||
borderRight: number
|
||||
timeRight: number
|
||||
@@ -108,6 +109,7 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
band: listRect.width - list.clientWidth,
|
||||
scrollbarEdgeOffset: sidebarEdge - listRect.right,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
|
||||
clientRight: listRect.left + list.clientWidth,
|
||||
borderRight: listRect.right,
|
||||
timeRight: time.getBoundingClientRect().right,
|
||||
@@ -129,7 +131,7 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
* @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'>> {
|
||||
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset' | 'rowPaintInset'>> {
|
||||
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')
|
||||
@@ -140,6 +142,7 @@ function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'r
|
||||
return {
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -193,7 +196,8 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
|
||||
`- list overflows: ${String(metrics.overflows)}`,
|
||||
`- reserved band: ${String(metrics.band)}px`,
|
||||
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
|
||||
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
|
||||
`- row hitbox inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
|
||||
`- row background inset from the sidebar edge: ${String(metrics.rowPaintInset)}px`,
|
||||
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
|
||||
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
|
||||
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
|
||||
@@ -321,7 +325,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
// Pin presence, not width, because the width is platform-dependent.
|
||||
expect(metrics.band).toBeGreaterThan(0)
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
expect(metrics.rowEdgeInset).toBe(8)
|
||||
expect(metrics.rowPaintInset).toBe(11)
|
||||
// Measure against the bar because overlay scrollbars do not move the client edge.
|
||||
expect(metrics.timeCoveredBy).toBe(0)
|
||||
expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
|
||||
@@ -359,12 +364,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
|
||||
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 })
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 8, rowPaintInset: 11 })
|
||||
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 })
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 8, rowPaintInset: 11 })
|
||||
} finally {
|
||||
await expandSeededSessions(page)
|
||||
}
|
||||
@@ -427,3 +432,218 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Measure painted glyphs and pointer targets independently of the row's layout cells.
|
||||
* @param row - visible, hovered Workspace or Session row.
|
||||
* @returns row bounds, action geometry, and browser hit-test results.
|
||||
*/
|
||||
async function measureRowActions(row: Locator) {
|
||||
return row.evaluate((element) => {
|
||||
const rowRect = element.getBoundingClientRect()
|
||||
const title = element.querySelector<HTMLElement>('[class*="title"]')
|
||||
if (title === null) throw new Error('row title not in the DOM')
|
||||
const buttons = [...element.querySelectorAll<HTMLButtonElement>('button')]
|
||||
return {
|
||||
height: rowRect.height,
|
||||
overflows: element.scrollWidth > element.clientWidth,
|
||||
titleRight: title.getBoundingClientRect().right,
|
||||
actions: buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const glyph = button.querySelector('svg')
|
||||
if (glyph === null) throw new Error('row action glyph not in the DOM')
|
||||
const glyphRect = glyph.getBoundingClientRect()
|
||||
const style = getComputedStyle(button)
|
||||
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
||||
// Side samples cover the outer target; corner samples stay inside the rounded border.
|
||||
const points = [
|
||||
{ x: center.x - 11.5, y: center.y },
|
||||
{ x: center.x + 11.5, y: center.y },
|
||||
{ x: center.x, y: center.y - 11.5 },
|
||||
{ x: center.x, y: center.y + 11.5 },
|
||||
{ x: center.x - 9.5, y: center.y - 9.5 },
|
||||
{ x: center.x + 9.5, y: center.y - 9.5 },
|
||||
{ x: center.x - 9.5, y: center.y + 9.5 },
|
||||
{ x: center.x + 9.5, y: center.y + 9.5 },
|
||||
]
|
||||
const hits = (x: number, y: number): boolean => {
|
||||
const target = document.elementFromPoint(x, y)
|
||||
return target !== null && button.contains(target)
|
||||
}
|
||||
return {
|
||||
label: button.getAttribute('aria-label'),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
layoutWidth: rect.width + Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight),
|
||||
layoutHeight: rect.height + Number.parseFloat(style.marginTop) + Number.parseFloat(style.marginBottom),
|
||||
margins: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
|
||||
pseudoContent: getComputedStyle(button, '::after').content,
|
||||
center,
|
||||
rowCenterOffset: center.y - (rowRect.y + rowRect.height / 2),
|
||||
trailingInset: rowRect.right - rect.right,
|
||||
glyphWidth: glyphRect.width,
|
||||
glyphHeight: glyphRect.height,
|
||||
glyphCenterOffset: {
|
||||
x: glyphRect.x + glyphRect.width / 2 - center.x,
|
||||
y: glyphRect.y + glyphRect.height / 2 - center.y,
|
||||
},
|
||||
insideRow: center.x - 12 >= rowRect.left && center.x + 12 <= rowRect.right
|
||||
&& center.y - 12 >= rowRect.top && center.y + 12 <= rowRect.bottom,
|
||||
points,
|
||||
edgeHits: points.map(({ x, y }) => hits(x, y)),
|
||||
// Clear Chromium's boundary rounding while still rejecting an oversized 28px target.
|
||||
outsideHits: [
|
||||
hits(center.x - 13.5, center.y), hits(center.x + 13.5, center.y),
|
||||
hits(center.x, center.y - 13.5), hits(center.x, center.y + 13.5),
|
||||
],
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('web e2e: sidebar search alignment and row action targets', () => {
|
||||
const workspaceTitle = 'Sidebar geometry workspace'
|
||||
const sessionTitle = 'Sidebar geometry session'
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, workspaceTitle)
|
||||
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-action-geometry')
|
||||
await workspace.attachSession(id)
|
||||
await scaffold.ctx.sessionController.rename({ sessionId: id, title: sessionTitle })
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
page = await newEnglishPage(browser, 800)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
|
||||
await workspace.waitFor({ timeout: 30_000 })
|
||||
await expect.poll(async () => {
|
||||
if (await workspace.getAttribute('aria-expanded') !== 'true') {
|
||||
await workspace.getByText(workspaceTitle, { exact: true }).click()
|
||||
}
|
||||
return workspace.getAttribute('aria-expanded')
|
||||
}, { timeout: 10_000 }).toBe('true')
|
||||
await page.locator('[data-row-key="session:sidebar-action-geometry"]').waitFor()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await page?.close()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() } finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
it('aligns the 32px expanded search with both edges of New session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-alignment'))
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions', exact: true })
|
||||
const input = page.getByPlaceholder('Search session names', { exact: true })
|
||||
const search = input.locator('..')
|
||||
const newSession = page.locator('button[class*="newSession"]')
|
||||
const measure = async () => {
|
||||
const searchBox = await search.boundingBox()
|
||||
const newSessionBox = await newSession.boundingBox()
|
||||
if (searchBox === null || newSessionBox === null) throw new Error('sidebar controls have no layout box')
|
||||
return {
|
||||
height: searchBox.height,
|
||||
leftOffset: searchBox.x - newSessionBox.x,
|
||||
rightOffset: searchBox.x + searchBox.width - newSessionBox.x - newSessionBox.width,
|
||||
}
|
||||
}
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate(dark => document.body.toggleAttribute('data-ds-dark-theme', dark), dark)
|
||||
await searchButton.click()
|
||||
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(measure, { timeout: 10_000 }).toEqual({ height: 32, leftOffset: 0, rightOffset: 0 })
|
||||
expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await input.press('Escape')
|
||||
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('false')
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['session', 'workspace'] as const)('keeps %s action glyphs in 16px layout space with 24px buttons', async (kind) => {
|
||||
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-action-targets`))
|
||||
const row = kind === 'session'
|
||||
? page.locator('[data-row-key="session:sidebar-action-geometry"]')
|
||||
: page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
|
||||
const restingBox = await row.boundingBox()
|
||||
await row.hover()
|
||||
const labels = kind === 'session'
|
||||
? [`Session actions for ${sessionTitle}`, 'Archive session', 'Pin session'] as const
|
||||
: [`Workspace actions for ${workspaceTitle}`, `New session in ${workspaceTitle}`] as const
|
||||
await row.getByRole('button', { name: labels[0], exact: true }).waitFor()
|
||||
const metrics = await measureRowActions(row)
|
||||
expect(await row.boundingBox()).toEqual(restingBox)
|
||||
expect(metrics.height).toBe(kind === 'session' ? 32 : 34)
|
||||
expect(metrics.overflows).toBe(false)
|
||||
expect(metrics.actions.map(action => action.label)).toEqual(labels)
|
||||
for (const [index, action] of metrics.actions.entries()) {
|
||||
expect(action.width).toBe(24)
|
||||
expect(action.height).toBe(24)
|
||||
expect(action.layoutWidth).toBe(16)
|
||||
expect(action.layoutHeight).toBe(16)
|
||||
expect(action.margins).toEqual(['-4px', '-4px', '-4px', '-4px'])
|
||||
expect(action.pseudoContent).toBe('none')
|
||||
expect(action.rowCenterOffset).toBe(0)
|
||||
expect(action.glyphWidth).toBe(index === 0 ? 16 : 14)
|
||||
expect(action.glyphHeight).toBe(index === 0 ? 16 : 14)
|
||||
expect(action.glyphCenterOffset).toEqual({ x: 0, y: 0 })
|
||||
expect(action.insideRow).toBe(true)
|
||||
expect(action.edgeHits).toEqual(Array<boolean>(8).fill(true))
|
||||
expect(action.outsideHits).toEqual([false, false, false, false])
|
||||
const previous = metrics.actions[index - 1]
|
||||
if (previous !== undefined) {
|
||||
expect(action.center.x - previous.center.x).toBe(29)
|
||||
expect(previous.center.x + 12).toBeLessThan(action.center.x - 12)
|
||||
} else {
|
||||
expect(metrics.titleRight).toBeLessThanOrEqual(action.center.x - 12)
|
||||
}
|
||||
}
|
||||
expect(metrics.actions.at(-1)?.trailingInset).toBe(7)
|
||||
|
||||
if (kind === 'session') expect(await row.getAttribute('aria-selected')).toBe('false')
|
||||
const selectedRows = page.locator('[role="tree"][aria-label="Sessions"] [aria-selected="true"]')
|
||||
const selectedKeys = await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))
|
||||
const expanded = await row.getAttribute('aria-expanded')
|
||||
const menuButton = row.getByRole('button', { name: labels[0], exact: true })
|
||||
const menu = page.getByRole('menu')
|
||||
const menuAction = kind === 'session' ? 'Pin session' : 'Rename'
|
||||
const first = metrics.actions[0]
|
||||
if (first === undefined) throw new Error('row has no menu trigger')
|
||||
for (const point of first.points.slice(0, 4)) {
|
||||
// Edge clicks exercise the enlarged target rather than the glyph's center.
|
||||
await page.mouse.click(point.x, point.y)
|
||||
await menu.getByRole('menuitem', { name: menuAction, exact: true }).waitFor()
|
||||
expect(await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))).toEqual(selectedKeys)
|
||||
expect(await row.getAttribute('aria-expanded')).toBe(expanded)
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
await menuButton.hover()
|
||||
}
|
||||
const tooltipAnchor = row.getByRole('button', { name: labels[1], exact: true })
|
||||
const tooltip = page.getByRole('tooltip').filter({ hasText: kind === 'session' ? 'Archive' : 'New session' })
|
||||
await tooltipAnchor.hover()
|
||||
await tooltip.waitFor({ timeout: 10_000 })
|
||||
const anchorBox = await tooltipAnchor.boundingBox()
|
||||
const tooltipBox = await tooltip.boundingBox()
|
||||
if (anchorBox === null || tooltipBox === null) throw new Error('hover tooltip or its anchor has no layout box')
|
||||
expect(tooltipBox.y).toBe(anchorBox.y + anchorBox.height + 8)
|
||||
expect(tooltipBox.x + tooltipBox.width).toBe(anchorBox.x + anchorBox.width)
|
||||
await pointAt(page, 'away')
|
||||
await tooltip.waitFor({ state: 'detached' })
|
||||
expect(await tooltipAnchor.isVisible()).toBe(false)
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 10be7f75a1576575
|
||||
zh: abf2905d89b0da48
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 3c79351ff2210ccb
|
||||
zh: 4cbe07ded89bbaa1
|
||||
en: 1230684d2a8b1eba
|
||||
zh: 212f105dde973947
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: b3744373b4b2c1fb
|
||||
zh: 2dbde8ee0bfa5f28
|
||||
|
||||
@@ -59,7 +59,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
|
||||
| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. |
|
||||
| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. |
|
||||
| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. The offscreen wrapper copies the source row's computed CSS custom properties to retain theme and layout variables outside its ancestors. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. |
|
||||
| `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. |
|
||||
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. |
|
||||
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
|
||||
|
||||
@@ -59,7 +59,7 @@ kind: "package-library"
|
||||
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
|
||||
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
|
||||
| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 |
|
||||
| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 |
|
||||
| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。离屏包装层复制源行计算后的 CSS 自定义属性,在脱离原祖先节点后保留主题和布局变量。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 |
|
||||
| `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 |
|
||||
| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 |
|
||||
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
|
||||
|
||||
@@ -7,6 +7,7 @@ import css from './RowDragPreview.module.css'
|
||||
/**
|
||||
* Own a row's inert drag preview until drag end, replacement, or unmount.
|
||||
* The offscreen copy keeps WebKit snapshots separate from overlapping panels.
|
||||
* The copy retains the source row's custom properties outside its layout ancestors.
|
||||
* Its cloned slot DOM remains in document.body for that lifetime; query live rows
|
||||
* within their list to exclude duplicate text and element IDs.
|
||||
* @param className - preview background matching the row's surface; WebKit fills
|
||||
@@ -48,6 +49,10 @@ export function useRowDragPreview(className: string): {
|
||||
image.className = clsx(css.preview, className)
|
||||
image.inert = true
|
||||
image.setAttribute('aria-hidden', 'true')
|
||||
for (let index = 0; index < style.length; index++) {
|
||||
const property = style.item(index)
|
||||
if (property.startsWith('--')) image.style.setProperty(property, style.getPropertyValue(property))
|
||||
}
|
||||
Object.assign(image.style, {
|
||||
left: `${String(-rect.width)}px`, width: `${String(rect.width)}px`,
|
||||
height: `${String(rect.height)}px`, font: style.font,
|
||||
|
||||
@@ -65,6 +65,22 @@ describe('row drag preview', () => {
|
||||
expect(third.isConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('retains computed custom properties outside the source layout ancestors', () => {
|
||||
const { row, start } = fixture(false)
|
||||
const original = globalThis.getComputedStyle
|
||||
const computed = document.createElement('div').style
|
||||
computed.cssText = original(row).cssText
|
||||
computed.setProperty('--row-inset', '3px')
|
||||
computed.setProperty('--row-label-gap', '6px')
|
||||
vi.spyOn(globalThis, 'getComputedStyle').mockImplementation((element, pseudo) =>
|
||||
element === row ? computed : original(element, pseudo))
|
||||
const image = start()
|
||||
expect(image.style.getPropertyValue('--row-inset')).toBe('3px')
|
||||
expect(image.style.getPropertyValue('--row-label-gap')).toBe('6px')
|
||||
expect(row.style.getPropertyValue('--row-inset')).toBe('')
|
||||
expect(row.style.getPropertyValue('--row-label-gap')).toBe('')
|
||||
})
|
||||
|
||||
it('removes a rejected custom preview while allowing native drag cleanup', () => {
|
||||
const { source, dataTransfer, start } = fixture(true)
|
||||
dataTransfer.setDragImage.mockImplementation(() => { throw new DOMException('Unavailable') })
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: c26f81e8adf0128b
|
||||
zh: e11ab371f51f66bc
|
||||
/deepseek-ai-dsh-client-ui-settings-account/use-this-package:
|
||||
en: 45aa7b44d5424d9b
|
||||
zh: 20c7d344c4e9bce0
|
||||
en: 287911020aedb764
|
||||
zh: 5a8c89af230b9e1f
|
||||
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
|
||||
en: 7ae4e68f9491b068
|
||||
zh: f7478269bf8d28b3
|
||||
|
||||
@@ -30,7 +30,7 @@ A new account-model sign-in-required event displays “Model unavailable. Please
|
||||
|
||||
Desktop and plain Web clients both mount the sidebar account launcher, Account settings, the sign-in step of model onboarding, and the account-state subscription. Desktop main opens the authorization page in the system browser. A plain Web client, identified by the absent Desktop preload bridge, sends `login_source` web with its own origin as the callback origin, preopens a tab during the sign-in click, navigates it to the authorization URL with the same `theme` parameter once its attempt receives one, and closes it on failure, expiry, or cancellation; a blocked popup leaves the copy-link route. Embedded Platform pages, Desktop onboarding, and the native device reader stay Desktop-only, so Web Platform links open new tabs.
|
||||
|
||||
The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. A rendered image failure updates the shared result, so both surfaces return to the icon and remain eligible for retry; reports from a superseded picture attempt are ignored. Each Settings entry, or return from a Settings or quota-notice top-up page, retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph.
|
||||
The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. A rendered image failure updates the shared result, so both surfaces return to the icon and remain eligible for retry; reports from a superseded picture attempt are ignored. Each Settings entry, or return from a Settings or quota-notice top-up page, retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a compact More row instead, leading with the ellipsis glyph and aligning its content and inset hover fill with navigation rows. The expanded signed-in launcher retains its 24px avatar, 6px padding, and 8px label gap; the Settings shell preserves its outer bounds independently of the compact row alignment.
|
||||
|
||||
The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ kind: "package-reference"
|
||||
|
||||
Desktop 与普通 Web 客户端都挂载侧边栏账号菜单、账号设置、模型引导中的登录步骤,并订阅账号状态。Desktop 主进程在系统浏览器中打开授权页面。普通 Web 客户端(以缺少 Desktop preload 桥接识别)以 `login_source` web 发起登录,并以自身 origin 作为回调 origin;它在点击登录时预先打开一个标签页,本次尝试获得授权 URL 后带上相同的 `theme` 参数导航该标签页,并在失败、过期或取消时关闭它;弹窗被拦截时仍可使用复制链接。内嵌 Platform 页面、Desktop 引导和原生设备信息读取器仅在 Desktop 中提供,因此 Web 中的 Platform 链接在新标签页打开。
|
||||
|
||||
侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。
|
||||
侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为紧凑的「更多」行,以三点图标开头,内容和内缩的悬停底色与导航行对齐。展开态已登录入口保留 24px 头像、6px 内边距及 8px 标签间距;设置外壳单独保留其外边界,不受紧凑行对齐影响。
|
||||
|
||||
该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
.root { flex: 1; min-width: 0; }
|
||||
.anchor { width: 100%; }
|
||||
.trigger { user-select: none; display: flex; align-items: center; width: 100%; height: 44px; gap: 8px; padding: 6px; border: 0; border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.trigger[data-collapsed='true'] { box-sizing: border-box; width: 36px; height: 36px; justify-content: center; gap: 0; padding: 0; }
|
||||
.trigger { user-select: none; display: flex; align-items: center; width: 100%; height: 44px; gap: 8px; padding: 6px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); border: 0; border-inline: var(--dsh-sidebar-paint-inset) solid transparent; box-sizing: border-box; border-radius: var(--dsw-radius-md); background: transparent; background-clip: padding-box; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.trigger[data-signed-out='false']:not([data-collapsed='true']) { padding: 6px; border-inline: 0; background-clip: border-box; }
|
||||
.trigger[data-collapsed='true'] { box-sizing: border-box; width: 36px; height: 36px; justify-content: center; gap: 0; padding: 0; border-inline: 0; background-clip: border-box; }
|
||||
@media (hover: hover) {
|
||||
.trigger:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.trigger:hover { background-color: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
.avatar { display: flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 50%;
|
||||
corner-shape: round; background: var(--dsw-alias-bg-skeleton); color: var(--dsw-alias-label-tertiary); }
|
||||
.label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Signed-out launcher: a 32px More row carrying the 14px ellipsis. */
|
||||
.trigger[data-signed-out='true']:not([data-collapsed='true']) { height: 32px; padding: 6px 2px 6px 6px; line-height: 20px; }
|
||||
.trigger[data-signed-out='true']:not([data-collapsed='true']) { height: 32px; padding: 6px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset)); line-height: 20px; }
|
||||
|
||||
/* Signed-out menu card and rows; listClassName reaches the portaled card. */
|
||||
.signedOutMenu[role='menu'] {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: ba55fe92bf49dc5a
|
||||
zh: 9a0e1345496a0ae6
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation:
|
||||
en: 1531b45780d0689f
|
||||
zh: 844548208c08a9c9
|
||||
en: d712fe4de4bb14f8
|
||||
zh: 265397283ed702f6
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/ledger-projections:
|
||||
en: 3e3c2c72f87e8b23
|
||||
zh: 12b10ba2d37c9077
|
||||
|
||||
@@ -66,7 +66,7 @@ Access: the settings shell and the `settings.open` shortcut are registered only
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
The shell declares settings.launcher for an account-owned sidebar menu and retains the Settings button as its fallback. The launcher receives settingsOpen, whose false-to-true edge marks one Settings entry, so a registrant acts once per entry instead of on every re-render inside one open. Closing the dialog returns focus to the active launcher.
|
||||
The shell declares settings.launcher for an account-owned sidebar menu and retains the Settings button as its fallback. The launcher receives settingsOpen, whose false-to-true edge marks one Settings entry, so a registrant acts once per entry instead of on every re-render inside one open. Closing the dialog returns focus to the active launcher. In the expanded sidebar, the fallback uses the navigation rows' compact icon-label alignment and inset hover fill; a signed-in account launcher instead keeps a root wrapper inset 2px on each side to preserve its outer bounds. The collapsed rail retains its centered 36 × 36 px control.
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
@@ -66,7 +66,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,其 false→true 边沿代表一次进入设置页,因此注册方在每次进入时执行一次,而不是在同一次打开中的每次重渲染执行。关闭对话框后,焦点返回当前入口。
|
||||
外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,其 false→true 边沿代表一次进入设置页,因此注册方在每次进入时执行一次,而不是在同一次打开中的每次重渲染执行。关闭对话框后,焦点返回当前入口。侧栏展开时,回退按钮采用导航行的紧凑图文对齐及内缩悬停底色;已登录账号入口则由根包装层左右各内缩 2px,保留其外边界。折叠轨道保留居中的 36 × 36 px 控件。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% + 4px);
|
||||
margin: 4px -2px;
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.triggerRow:not(.railRow):has(button[data-signed-out='false']) {
|
||||
width: calc(100% - 4px);
|
||||
margin-inline: 2px;
|
||||
}
|
||||
|
||||
.triggerRow.railRow {
|
||||
@@ -18,15 +23,17 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
width: auto;
|
||||
height: 42px;
|
||||
margin: 0;
|
||||
padding: 0 10px 0 8px;
|
||||
padding: 0 calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
background-clip: padding-box;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -37,7 +44,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.trigger:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +58,8 @@
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-inline: 0;
|
||||
background-clip: border-box;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
@@ -173,7 +182,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.navCell:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +241,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.close:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 8855ad013285a27e
|
||||
zh: f19b3e75c38c5b0a
|
||||
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation:
|
||||
en: 39b5e3ca8ce8c5c2
|
||||
zh: 1d652b7554772dad
|
||||
en: f56d373d62c574fc
|
||||
zh: 7d085555f9a22e05
|
||||
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline:
|
||||
en: 6eefc5c45abe8a8f
|
||||
zh: 20b651e48a0ea903
|
||||
|
||||
@@ -76,6 +76,8 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
|
||||
|
||||
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
|
||||
|
||||
Expanded navigation uses 8px outer gutters, hover and selected fills inset 3px on each side, and a 16px leading cell with a 6px label gap. New Session centers its content with 4px side margins and a 10px bottom margin. The logo row has no bottom margin; its content starts 16px from the column's left edge, with 5px between mark and name. Only the macOS non-fullscreen top toggle shifts 4px right while expanded and not fading. Collapsed geometry stays independent.
|
||||
|
||||
### Slot discipline
|
||||
|
||||
Declaration-aware `slots.inject()` lets a replacing package activate before or after the sidebar. The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`). The `/client` exports are the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration.
|
||||
|
||||
@@ -76,6 +76,8 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
|
||||
|
||||
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
|
||||
|
||||
展开态导航的左右外距为 8px,悬停与选中底色在两侧各内缩 3px,前置单元宽 16px,与标签间隔 6px。新会话内容居中,左右外边距为 4px,底部外边距为 10px。logo 行无底部外边距;内容从侧栏左边缘向内 16px 开始,标记与名称间隔 5px。仅 macOS 非全屏、侧栏展开且未淡出时,顶部开关向右偏移 4px。折叠态尺寸独立保留。
|
||||
|
||||
### slot 纪律
|
||||
|
||||
声明感知的 `slots.inject()` 让替换包无论先于还是后于侧边栏激活都能生效。页脚承载 `sidebar.settings` 席位:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`)。`/client` 导出接口只包含插件主体(`apply`/`inject`)及约定类型;SidebarRoot、行组件与树派生仍由 slot 注册封装在包内。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar
|
||||
/* Sidebar column: vertical stack, padding 8/6, sidebar
|
||||
fill + 1px right border painted by the layout column. Collapse is a
|
||||
slide + crossfade, not a morph: the content holds its frozen expanded
|
||||
layout (inline width set by the component) and fades in place (.fading)
|
||||
@@ -7,7 +7,10 @@
|
||||
mid-slide. */
|
||||
|
||||
.root {
|
||||
--dsh-sidebar-inline-padding: 12px;
|
||||
--dsh-sidebar-inline-padding: 8px;
|
||||
--dsh-sidebar-row-padding: 12px;
|
||||
--dsh-sidebar-label-gap: 6px;
|
||||
--dsh-sidebar-paint-inset: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -55,7 +58,7 @@
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
|
||||
padding-left: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .brandIdentity {
|
||||
@@ -75,8 +78,8 @@
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
|
||||
margin-top: 8px;
|
||||
margin-left: 0;
|
||||
margin-top: 6px;
|
||||
margin-left: calc(var(--dsh-sidebar-paint-inset) + 1px);
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .collapsed .panelList,
|
||||
@@ -192,6 +195,10 @@
|
||||
padding: 0 12px 2px;
|
||||
}
|
||||
|
||||
:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed):not(.fading) > .topStrip > .toggle {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop
|
||||
preload): the toggle takes their spot at the strip's left edge. */
|
||||
:global([data-fullscreen]) .topStrip {
|
||||
@@ -224,8 +231,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 0 8px 4px;
|
||||
margin-bottom: 4px;
|
||||
padding: 8px 0 8px 8px;
|
||||
margin-bottom: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -268,7 +275,7 @@
|
||||
.brandIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 5px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -341,7 +348,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +423,7 @@
|
||||
gap: 0;
|
||||
height: 38px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 2px 12px;
|
||||
margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px;
|
||||
box-sizing: border-box;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
@@ -471,7 +478,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.collapsed .newSession:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,14 +539,16 @@
|
||||
.panelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 2px;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
padding: 7px 8px;
|
||||
padding: 7px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
background-clip: padding-box;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
line-height: 22px;
|
||||
@@ -549,12 +558,12 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.panelRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.panelRow.panelActive {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -570,7 +579,7 @@
|
||||
}
|
||||
|
||||
.panelRow[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.moreMenu {
|
||||
@@ -602,6 +611,8 @@
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
border-inline: 0;
|
||||
background-clip: border-box;
|
||||
padding: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ describe('SidebarRoot.module.css', () => {
|
||||
|
||||
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('--dsh-sidebar-inline-padding')).toBe('8px')
|
||||
expect(root?.get('--dsh-sidebar-row-padding')).toBe('12px')
|
||||
expect(root?.get('--dsh-sidebar-label-gap')).toBe('6px')
|
||||
expect(root?.get('--dsh-sidebar-paint-inset')).toBe('3px')
|
||||
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
|
||||
expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px')
|
||||
expect(declarations('.regionArea')?.get('padding-left')).toBe('4px')
|
||||
@@ -45,6 +48,16 @@ describe('SidebarRoot.module.css', () => {
|
||||
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
|
||||
})
|
||||
|
||||
it('keeps the reviewed expanded spacing separate from rail and caption controls', () => {
|
||||
expect(declarations('.logoRow')?.get('padding')).toBe('8px 0 8px 8px')
|
||||
expect(declarations('.logoRow')?.get('margin-bottom')).toBe('0')
|
||||
expect(declarations('.newSession')?.get('margin')).toBe('0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px')
|
||||
expect(declarations('.newSessionContent')?.get('justify-content')).toBe('center')
|
||||
expect(declarations(":global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed):not(.fading) > .topStrip > .toggle")?.get('transform')).toBe('translateX(4px)')
|
||||
expect(declarations('.topStrip')?.get('height')).toBe('52px')
|
||||
expect(declarations('.root.collapsed')?.get('padding')).toBe('18px 10px 6px')
|
||||
})
|
||||
|
||||
it('moves the four upper controls while the settings seat only fades', () => {
|
||||
const animation = 'rail-in 150ms var(--ds-ease-in-out) backwards'
|
||||
for (const selector of [
|
||||
@@ -69,7 +82,8 @@ describe('SidebarRoot.module.css', () => {
|
||||
expect(declarations('.collapsed .newSession')?.get('width')).toBe('36px')
|
||||
})
|
||||
|
||||
it('keeps the slotted brand row at the full artwork height', () => {
|
||||
it('keeps full-size brand artwork with a compact mark-to-name gap', () => {
|
||||
expect(declarations('.brandIdentity')?.get('gap')).toBe('5px')
|
||||
expect(declarations('.brandIdentity')?.get('height')).toBe('24px')
|
||||
expect(declarations('.brandName')?.get('height')).toBe('24px')
|
||||
expect(declarations('.brandName')?.get('line-height')).toBe('24px')
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 2168316f604893a5
|
||||
zh: fe11347009339a9a
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
|
||||
en: 96a37e74555a36b7
|
||||
zh: d03941523509a5cf
|
||||
en: 298c0358edd2d7ef
|
||||
zh: 4d4c4a6be3e70434
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole:
|
||||
en: 868904dbfb5713f8
|
||||
zh: 225906bfef7398bf
|
||||
@@ -51,8 +51,8 @@
|
||||
en: ad4a333892f2fe92
|
||||
zh: 3a670fd401f4d7a7
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/view-state:
|
||||
en: b62b5a344b962c45
|
||||
zh: 6c773bece95c3621
|
||||
en: 53286475641f45cb
|
||||
zh: f6334e9e536b8b51
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
|
||||
en: 8bcb7bd90d7c5773
|
||||
zh: 17663be926a099d2
|
||||
|
||||
@@ -99,7 +99,7 @@ The browser entry declares three root-scoped `list` child seats on each Session
|
||||
|
||||
The leading seat occupies the status cell only while the row's primary status is idle and leaves archived rows blank. The trailing seat accepts non-interactive icons in ascending `order`, after the time label and before the pin marker. Blank rows omit the trailing seat; an empty seat adds no spacing. The hover seat mounts only while the row's hover card is open.
|
||||
|
||||
On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves.
|
||||
On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves. Built-in Session and Workspace row action buttons use 24px click targets with compensated margins that retain their compact layout footprint and icon positions.
|
||||
|
||||
Hoverless devices keep the static details and the More menu trigger visible, including while the menu is open; pin and archive remain available in that menu.
|
||||
|
||||
@@ -188,6 +188,8 @@ A dynamically loaded browser half follows the same component contract; which mod
|
||||
|
||||
### View state
|
||||
|
||||
Workspace and Session rows share a 16px leading cell and 6px label gap, with 8px trailing padding and 13px between actions. The built-in More action uses a 16px glyph; other built-in row actions use 14px glyphs. The time label has an additional 2px right margin. The Workspace heading label has a -2px left offset. Session insertion markers compensate for the transparent side borders without moving row content.
|
||||
|
||||
Once the Workspace baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions receive complete account orders, never filtered rows. A new member without a Session summary waits for that summary, while a saved position survives a temporarily missing summary. Archive visibility is applied only when deriving rows. Pin and drag writes save complete orders; ordinary derivation does not write them. The selected blank Session remains an explicit position write, including during Workspace reconnection, when other saved members are retained until the baseline establishes membership. Ordering remains mounted while the sidebar is a rail or search replaces its body. Last updated derives from current summaries without reading saved positions; equal timestamps use Session ids as a stable tie-break.
|
||||
|
||||
The sidebar hides durable summaries with `origin: 'subagent'`. A visible ordinary row shows the shared ongoing loader from running direct children in its loaded parent catalog, never from summary lineage. Child activity uses the latest UI status, falling back to the Session summary until that status is known.
|
||||
|
||||
@@ -99,7 +99,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
|
||||
|
||||
前置 slot 仅在该行主状态为 idle 时占用状态单元,归档行留空。尾部 slot 按 `order` 升序放置非交互图标,位置在时间标签之后、置顶标记之前。空白行不渲染尾部 slot;空 slot 不增加间距。悬浮 slot 仅在该行的悬浮卡片打开时挂载。
|
||||
|
||||
在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。
|
||||
在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。内置 Session 和 Workspace 行操作按钮使用 24px 点击热区,通过补偿外边距保留紧凑的布局占位与图标位置。
|
||||
|
||||
不支持悬停的设备始终保留静态信息和更多菜单入口,菜单打开时也不隐藏;置顶与归档仍可通过该菜单操作。
|
||||
|
||||
@@ -188,6 +188,8 @@ export function apply(ctx: Context): void {
|
||||
|
||||
### 视图状态
|
||||
|
||||
Workspace 与 Session 行共用 16px 前置单元和 6px 标签间距,行尾内边距为 8px,操作间距为 13px。内置更多操作的图标为 16px,其他内置行操作图标为 14px。时间标签额外保留 2px 右外边距。工作区区头标签的左偏移为 -2px。Session 插入标记补偿两侧透明边框,不移动行内容。
|
||||
|
||||
Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作接收完整记账顺序,而不是筛选后的行。尚无 Session 摘要的新成员会等待摘要,已保存的位置则在摘要暂时缺失时保留。归档显隐仅在派生行时应用。置顶和拖拽写入完整顺序,普通派生不执行写入。当前选中的空白 Session 仍是一次显式位置写入;Workspace 重连时同样如此,此时保留其他已保存成员,直到基线确定成员关系。侧边栏收成窄栏或搜索替代列表主体时,排序仍保持挂载。最近更新从当前摘要派生,不读取已保存位置;时间相同时按 Session id 稳定排序。
|
||||
|
||||
侧边栏隐藏持久化摘要中带有 `origin: 'subagent'` 的行。可见普通行的共享 ongoing loading 来自其已加载 parent 目录中正在运行的直接 child,绝不来自摘要谱系。Child 活动状态使用最新 UI status,尚无该状态时使用 Session 摘要。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
background-clip: padding-box;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
padding-inline-start: calc(8px + var(--dsh-workspace-indent, 0px));
|
||||
padding-inline-start: calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset) + var(--dsh-workspace-indent, 0px));
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
@@ -14,12 +17,12 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.projectRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.sessionRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.dragPreview {
|
||||
@@ -35,8 +38,10 @@
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
padding: 4px 8px;
|
||||
padding: 4px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
background: transparent;
|
||||
background-clip: padding-box;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -44,12 +49,12 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.searchResultRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.searchResultRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchResultHeading {
|
||||
@@ -61,7 +66,7 @@
|
||||
.searchResultTitle {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
margin-left: 4px;
|
||||
margin-left: var(--dsh-sidebar-label-gap);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -74,7 +79,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-left: 20px;
|
||||
margin-left: calc(16px + var(--dsh-sidebar-label-gap));
|
||||
}
|
||||
|
||||
.searchResultWorkspace,
|
||||
@@ -114,14 +119,14 @@
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
|
||||
/* Session titles share the workspace icon slot and compact label gap. */
|
||||
.sessionRow {
|
||||
height: 32px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sessionRow .title {
|
||||
margin: 0 6px 0 4px;
|
||||
margin: 0 6px 0 var(--dsh-sidebar-label-gap);
|
||||
}
|
||||
|
||||
.slot {
|
||||
@@ -235,6 +240,7 @@
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
margin-inline-end: -1px;
|
||||
}
|
||||
|
||||
.pinIndicator {
|
||||
@@ -263,12 +269,12 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Trailing action buttons surface on hover with bare 16px glyphs. */
|
||||
/* The trailing strip keeps a shared gap between its fixed-size control boxes. */
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -310,7 +316,7 @@
|
||||
unguarded rule would strand that row without its ellipsis. */
|
||||
@media (hover: hover) {
|
||||
.sessionRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sessionRow:hover .rowActions,
|
||||
@@ -347,7 +353,7 @@
|
||||
hover fill while its dropdown is up). */
|
||||
.projectRow.menuOpen,
|
||||
.sessionRow.menuOpen {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Session drag insert marker: a leading chevron and 2px rule between rows,
|
||||
@@ -362,8 +368,8 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 4px;
|
||||
left: calc(-1 * var(--dsh-sidebar-paint-inset));
|
||||
right: calc(4px - var(--dsh-sidebar-paint-inset));
|
||||
height: 12px;
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -434,13 +440,15 @@
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
/* Compensated margins keep the 24px target on the compact icon's center. */
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: -4px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
padding: 0;
|
||||
@@ -449,6 +457,11 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Keep the focus ring on its original compact outline. */
|
||||
.iconButton:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
|
||||
@@ -349,7 +349,7 @@ function ProjectRow({
|
||||
aria-label={t('actions.newSession.aria', { name: label })}
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconNewChatOutlineRegular />
|
||||
<IconNewChatOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 4px;
|
||||
padding-left: var(--dsh-sidebar-row-padding);
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
@@ -73,6 +73,8 @@
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
position: relative;
|
||||
left: -1px;
|
||||
flex: none;
|
||||
max-width: 45%;
|
||||
min-width: 0;
|
||||
@@ -167,10 +169,20 @@
|
||||
background-color 180ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader:has(.searchExpanded) {
|
||||
margin-right: 0;
|
||||
padding-inline: calc(var(--dsh-sidebar-paint-inset) + 1px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sectionHeader:has(.searchExpanded) .sectionLabelHidden {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.searchExpanded {
|
||||
width: calc(100% + 4px);
|
||||
height: 30px;
|
||||
margin-inline: -2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-inline: 0;
|
||||
padding: 0 4px 0 0;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
@@ -195,7 +207,7 @@
|
||||
|
||||
.searchExpanded .searchButton {
|
||||
width: 28px;
|
||||
height: 30px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -402,10 +414,10 @@
|
||||
/* Top alignment lines the 16px dot up with the equally tall title bar;
|
||||
the paddings center the 35px content in the 48px row. */
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
padding: 6px 8px 7px;
|
||||
padding: 6px var(--dsh-sidebar-row-padding) 7px;
|
||||
}
|
||||
|
||||
.skeletonDot {
|
||||
@@ -509,7 +521,7 @@
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 0 12px 0 calc(28px + var(--dsh-workspace-indent, 0px));
|
||||
padding: 0 var(--dsh-sidebar-row-padding) 0 calc(var(--dsh-sidebar-row-padding) + 16px + var(--dsh-sidebar-label-gap) + var(--dsh-workspace-indent, 0px));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
@@ -83,7 +83,8 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
expect(listTopMarker?.get('top')).toBe('-8px')
|
||||
expect(listTopMarker?.get('left')).toBe('0')
|
||||
expect(workspaceMarker?.get('left')).toBe('0')
|
||||
expect(sessionMarker?.get('left')).toBe('0')
|
||||
expect(sessionMarker?.get('left')).toBe('calc(-1 * var(--dsh-sidebar-paint-inset))')
|
||||
expect(sessionMarker?.get('right')).toBe('calc(4px - var(--dsh-sidebar-paint-inset))')
|
||||
for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) {
|
||||
expect(marker?.get('height')).toBe('12px')
|
||||
expect(marker?.get('background')).not.toContain('radial-gradient')
|
||||
@@ -99,7 +100,7 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
it('keeps the compact fade, overflow control, search field, and row heights', () => {
|
||||
expect(declarations('.fade')?.get('height')).toBe('24px')
|
||||
expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px')
|
||||
expect(declarations('.searchExpanded')?.get('height')).toBe('30px')
|
||||
expect(declarations('.searchExpanded')?.get('height')).toBe('32px')
|
||||
expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px')
|
||||
expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px')
|
||||
// One leading status cell for every session row, grouped or flat: the cell
|
||||
@@ -107,12 +108,47 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
// own box, so the title's one shared margin below needs no per-view override.
|
||||
expect(rowDeclarations('.slot')?.get('width')).toBe('16px')
|
||||
expect(rowDeclarations('.slot')?.get('height')).toBe('20px')
|
||||
expect(rowDeclarations('.sessionRow .title')?.get('margin')).toBe('0 6px 0 4px')
|
||||
expect(rowDeclarations('.sessionRow .title')?.get('margin')).toBe('0 6px 0 var(--dsh-sidebar-label-gap)')
|
||||
expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px')
|
||||
expect(rowDeclarations('.sessionRow.selected')?.get('background'))
|
||||
expect(rowDeclarations('.sessionRow.selected')?.get('background-color'))
|
||||
.toBe('var(--dsw-alias-interactive-bg-hover)')
|
||||
})
|
||||
|
||||
it('shares action spacing and inset backgrounds without moving the title column', () => {
|
||||
for (const selector of ['.projectRow', '.sessionRow']) {
|
||||
expect(rowDeclarations(selector)?.get('padding')).toBe('0 8px')
|
||||
expect(rowDeclarations(selector)?.get('background-clip')).toBe('padding-box')
|
||||
expect(rowDeclarations(selector)?.get('border-inline')).toBe('var(--dsh-sidebar-paint-inset) solid transparent')
|
||||
}
|
||||
expect(rowDeclarations('.rowActions')?.get('gap')).toBe('13px')
|
||||
expect(rowDeclarations('.iconButton')?.get('width')).toBe('24px')
|
||||
expect(rowDeclarations('.time')?.get('margin-inline-end')).toBe('-1px')
|
||||
expect(declarations('.sectionLabel')?.get('left')).toBe('-1px')
|
||||
})
|
||||
|
||||
it('extends native row action targets while retaining the compact layout footprint', () => {
|
||||
const button = rowDeclarations('.iconButton')
|
||||
expect(button?.get('width')).toBe('24px')
|
||||
expect(button?.get('height')).toBe('24px')
|
||||
expect(button?.get('margin')).toBe('-4px')
|
||||
expect(button?.get('background')).toBe('transparent')
|
||||
expect(rowDeclarations('.iconButton::after')).toBeUndefined()
|
||||
expect(rowDeclarations('.iconButton:focus-visible')?.get('outline-offset')).toBe('-2px')
|
||||
})
|
||||
|
||||
it('aligns the expanded search field without changing list scroll ownership', () => {
|
||||
const header = declarations('.root:not(.rail) .sectionHeader:has(.searchExpanded)')
|
||||
expect(header?.get('margin-right')).toBe('0')
|
||||
expect(header?.get('padding-inline')).toBe('calc(var(--dsh-sidebar-paint-inset) + 1px)')
|
||||
expect(header?.get('gap')).toBe('0')
|
||||
expect(declarations('.searchExpanded')?.get('width')).toBe('100%')
|
||||
expect(declarations('.searchExpanded')?.get('margin-inline')).toBe('0')
|
||||
expect(declarations('.searchExpanded .searchButton')?.get('height')).toBe('32px')
|
||||
expect(declarations('.sectionHeader:has(.searchExpanded) .sectionLabelHidden')?.get('margin-right')).toBe('0')
|
||||
expect(declarations('.root:not(.rail) .sectionHeader')?.get('position')).toBeUndefined()
|
||||
expect(declarations('.list')?.get('overflow-y')).toBe('auto')
|
||||
})
|
||||
|
||||
it('marquees a clipped session title on row hover', () => {
|
||||
// The crawl itself is scripted in Rows.tsx frame by frame, so the title
|
||||
// declares no scroll-behavior; the stylesheet keeps the hovered cell
|
||||
|
||||
@@ -349,6 +349,7 @@ describe('workspace browser rows', () => {
|
||||
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: '在“Project”中新建会话' }).getAttribute('aria-keyshortcuts')).toBe('Control+N')
|
||||
expect(screen.getByRole('button', { name: '在“Project”中新建会话' }).querySelector('svg')?.getAttribute('width')).toBe('14')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
- list overflows: true
|
||||
- reserved band: 5px
|
||||
- scrollbar inset from the sidebar edge: 2px
|
||||
- row background inset from the sidebar edge: 12px
|
||||
- row hitbox inset from the sidebar edge: 8px
|
||||
- row background inset from the sidebar edge: 11px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
@@ -33,7 +34,8 @@
|
||||
- list overflows: true
|
||||
- reserved band: 5px
|
||||
- scrollbar inset from the sidebar edge: 2px
|
||||
- row background inset from the sidebar edge: 12px
|
||||
- row hitbox inset from the sidebar edge: 8px
|
||||
- row background inset from the sidebar edge: 11px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
Reference in new issue
Block a user