feat(workspace): restore the hover title reveal with an 8px jitter floor
Hovering a session row again scrolls a clipped title to its far edge (the #4423 reveal), but overflow of at most 8px stays put so barely clipped titles do not jitter. The hover mask fade yields back to plain clipping — at the scrolled extent the fade would cover the revealed tail. Row action buttons tighten from gap 12 to 10.
This commit is contained in:
1 parent
1d221f9723
commit
34d78d843d
5 files changed
+212
-12
No files matched your search
@@ -0,0 +1,86 @@
|
||||
// Web e2e scenario: hovering a sidebar session row reveals a title wider than
|
||||
// its cell. The jsdom lane pins only the scroll position the handler writes;
|
||||
// the assembled browser is where the title really clips, really reaches its own
|
||||
// hovered extent (the trailing relative-time cell yields to the row menu while
|
||||
// the pointer rests on the row, so the hovered cell is wider than the resting
|
||||
// one), and really returns to its start when the pointer leaves.
|
||||
//
|
||||
// Zero model calls: seeding one renamed session and hovering its row touches no
|
||||
// provider.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */
|
||||
const TITLE = 'Forked session clipped before its suffix (1)'
|
||||
|
||||
describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => {
|
||||
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)
|
||||
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll')
|
||||
await workspace.attachSession(id)
|
||||
await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('reveals the clipped title under the pointer and restores its start after', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll'))
|
||||
const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) })
|
||||
await row.waitFor({ timeout: 20_000 })
|
||||
const title = row.getByText(TITLE, { exact: true })
|
||||
|
||||
// At rest the row clips its title with an ellipsis and holds its start.
|
||||
expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0)
|
||||
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
|
||||
expect(await title.evaluate(el => el.scrollLeft)).toBe(0)
|
||||
expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth')
|
||||
|
||||
await row.hover()
|
||||
// Poll against the live extent: the reveal must reach the far edge of the
|
||||
// hovered cell (the wider one — the relative-time label yields to the row
|
||||
// menu), whatever width the handler itself read.
|
||||
await expect.poll(
|
||||
async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
// Scrolled to its end, the title must not paint the ellipsis over the
|
||||
// characters the reveal reached.
|
||||
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip')
|
||||
|
||||
// Leaving returns in one step: a smooth return would still be travelling
|
||||
// inside this window, and it would carry the resting ellipsis back with it.
|
||||
await page.mouse.move(0, 0)
|
||||
await expect.poll(
|
||||
async () => title.evaluate(el => el.scrollLeft),
|
||||
{ timeout: 150, interval: 20 },
|
||||
).toBe(0)
|
||||
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
|
||||
|
||||
// The reduce override reaches the real stylesheet, so the reveal jumps
|
||||
// instead of gliding.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto')
|
||||
await page.emulateMedia({ reducedMotion: null })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -184,8 +184,13 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Hovering a session row reveals a title wider than its cell: Rows.tsx scrolls
|
||||
it to its end while the pointer rests on the row — smoothly, through the
|
||||
scroll-behavior here — and returns it to the start in one step when the
|
||||
pointer leaves. */
|
||||
.sessionRow .title {
|
||||
flex: 1;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@@ -248,12 +253,13 @@
|
||||
}
|
||||
|
||||
/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656):
|
||||
bare 16px glyphs, gap 12, tertiary grey. */
|
||||
bare 16px glyphs, tertiary grey; the figma gap 12 reads too airy beside
|
||||
the 16px glyphs, so the buttons sit at 10. */
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.projectRow:hover .rowActions,
|
||||
@@ -282,15 +288,15 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hover truncation fades the title's own alpha out instead of clipping to
|
||||
"…": an overlay gradient would need the sidebar's fill, which macOS
|
||||
vibrancy makes translucent, while a mask works over any backdrop. Short
|
||||
titles never reach the faded band, so only overflow is affected. */
|
||||
.sessionRow:hover .title,
|
||||
.sessionRow.menuOpen .title {
|
||||
text-overflow: clip;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
|
||||
/* The unclipped hover state drops the ellipsis, which would otherwise cover
|
||||
the characters the scroll reveal reached. Guarded because a touch tap
|
||||
latches `:hover` on the row after the title has already returned: an
|
||||
unguarded rule would strand that row without its ellipsis. */
|
||||
@media (hover: hover) {
|
||||
.sessionRow:hover .title,
|
||||
.sessionRow.menuOpen .title {
|
||||
text-overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
/* An open row menu pins the hover affordances (figma: the row keeps its
|
||||
@@ -414,4 +420,9 @@
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* The title reveal jumps to the far edge instead of gliding there. */
|
||||
.sessionRow .title {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,37 @@ function displayTitle(node: SessionNode, t: RowTranslate): string {
|
||||
return node.blank ? t('session.new') : node.title
|
||||
}
|
||||
|
||||
/* Overflow this small hides no meaningful tail; scrolling for it reads as an
|
||||
accidental jitter, so the title stays put. */
|
||||
const MIN_TITLE_REVEAL_PX = 8
|
||||
|
||||
/**
|
||||
* Reveal a title wider than its one-line cell while its row is hovered: the
|
||||
* title clips its own text, so the far edge (a fork's incremented title, for
|
||||
* example) is reachable by scrolling the element to its end. Overflow of at
|
||||
* most {@link MIN_TITLE_REVEAL_PX} stays put — a barely-clipped title moving a
|
||||
* few pixels reads as jitter, not a reveal. Leaving returns the title to the
|
||||
* start in one step, because the resting ellipsis and the narrowed cell would
|
||||
* otherwise meet the text while it travelled back. The stylesheet decides
|
||||
* whether either move glides or jumps.
|
||||
* @param title - the row's clipping title element.
|
||||
* @param revealed - whether the pointer is on the row.
|
||||
*/
|
||||
function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void {
|
||||
/* v8 ignore next -- defensive: the title span renders unconditionally. */
|
||||
if (title === null) return
|
||||
if (revealed) {
|
||||
const range = title.scrollWidth - title.clientWidth
|
||||
if (range <= MIN_TITLE_REVEAL_PX) return
|
||||
title.scrollLeft = range
|
||||
return
|
||||
}
|
||||
// jsdom implements no scrollTo; the lane's direct assignment is instant there
|
||||
// anyway, so both paths land on the same resting position.
|
||||
if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' })
|
||||
else title.scrollLeft = 0
|
||||
}
|
||||
|
||||
/** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */
|
||||
function timeLabel(updatedAt: number, now: number, t: RowTranslate): string {
|
||||
const { unit, n } = relativeTime(updatedAt, now)
|
||||
@@ -472,6 +503,7 @@ export function SessionNodeItem({
|
||||
const draggable = drag !== undefined && !row.blank && !row.archived
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const titleRef = useRef<HTMLSpanElement>(null)
|
||||
useEffect(() => {
|
||||
if (onReveal === undefined) return
|
||||
rowRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
@@ -509,6 +541,8 @@ export function SessionNodeItem({
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
onClick={() => { onOpen(node.id) }}
|
||||
onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }}
|
||||
onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }}
|
||||
draggable={draggable}
|
||||
onDragStart={!draggable
|
||||
? undefined
|
||||
@@ -545,6 +579,7 @@ export function SessionNodeItem({
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
ref={titleRef}
|
||||
className={css.title}
|
||||
onDoubleClick={row.blank
|
||||
? undefined
|
||||
|
||||
@@ -108,7 +108,11 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
.toBe('var(--dsw-alias-interactive-bg-hover)')
|
||||
})
|
||||
|
||||
it('fades a hovered title out through the mask instead of the resting ellipsis', () => {
|
||||
it('reveals a clipped session title by scrolling it on row hover', () => {
|
||||
// Smooth versus reduced motion is pinned as a computed style in
|
||||
// apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges
|
||||
// same-selector rules across media queries, so the reduce override would
|
||||
// mask the smooth declaration here.
|
||||
expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1')
|
||||
expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip')
|
||||
})
|
||||
|
||||
@@ -172,6 +172,70 @@ describe('workspace browser rows', () => {
|
||||
expect(onOpen).toHaveBeenCalledWith(node.id)
|
||||
})
|
||||
|
||||
it('reveals a clipped session title by scrolling it while the row is hovered', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false,
|
||||
running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
|
||||
pinned: false, archived: false,
|
||||
}
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
const title = screen.getByText(node.title)
|
||||
// jsdom lays out nothing: the title's clipped geometry is stated outright.
|
||||
const geometry = (scrollWidth: number, clientWidth: number): void => {
|
||||
Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true })
|
||||
Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true })
|
||||
}
|
||||
|
||||
geometry(320, 180)
|
||||
fireEvent.pointerEnter(row)
|
||||
expect(title.scrollLeft).toBe(140)
|
||||
fireEvent.pointerLeave(row)
|
||||
expect(title.scrollLeft).toBe(0)
|
||||
|
||||
// A title that fits has no scroll range: hovering leaves it at its start.
|
||||
geometry(180, 180)
|
||||
fireEvent.pointerEnter(row)
|
||||
expect(title.scrollLeft).toBe(0)
|
||||
fireEvent.pointerLeave(row)
|
||||
|
||||
// Overflow at the 8px jitter threshold also stays put.
|
||||
geometry(188, 180)
|
||||
fireEvent.pointerEnter(row)
|
||||
expect(title.scrollLeft).toBe(0)
|
||||
fireEvent.pointerLeave(row)
|
||||
|
||||
// One pixel past the threshold reveals.
|
||||
geometry(189, 180)
|
||||
fireEvent.pointerEnter(row)
|
||||
expect(title.scrollLeft).toBe(9)
|
||||
})
|
||||
|
||||
it('returns a revealed title to its start in one step', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false,
|
||||
running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
|
||||
pinned: false, archived: false,
|
||||
}
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} onUnarchive={vi.fn()} onPin={vi.fn()} t={t} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
const title = screen.getByText(node.title)
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true })
|
||||
|
||||
fireEvent.pointerEnter(row)
|
||||
fireEvent.pointerLeave(row)
|
||||
// Browsers receive the explicit instant behavior that the stylesheet's
|
||||
// smooth scroll-behavior would otherwise override.
|
||||
expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => {
|
||||
const onOpen = vi.fn()
|
||||
const node: SessionNode = {
|
||||
|
||||
Reference in new issue
Block a user