From 13e8829206b72f7522f7fcc3cd2f77f3ef22f2ed Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Thu, 20 Aug 2026 10:38:37 -0300 Subject: [PATCH] improve(ui): reveal only clipped session titles --- .../app-sidebar-session-catalog-render.ts | 6 +- .../app-sidebar-session-row-render.ts | 41 ++--- ...chat-flow.sidebar-presentation.e2e.test.ts | 142 ++++++++++++------ ui/src/lib/hover-marquee.test.ts | 67 --------- ui/src/lib/hover-marquee.ts | 63 -------- ui/src/lib/overflow-fade.test.ts | 70 +++++++++ ui/src/lib/overflow-fade.ts | 45 ++++++ ui/src/styles/components.css | 34 ----- ui/src/styles/layout.css | 60 ++++++-- 9 files changed, 286 insertions(+), 242 deletions(-) delete mode 100644 ui/src/lib/hover-marquee.test.ts delete mode 100644 ui/src/lib/hover-marquee.ts create mode 100644 ui/src/lib/overflow-fade.test.ts create mode 100644 ui/src/lib/overflow-fade.ts diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts index a1090c4ff5af..40ee45c64604 100644 --- a/ui/src/components/app-sidebar-session-catalog-render.ts +++ b/ui/src/components/app-sidebar-session-catalog-render.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { ref } from "lit/directives/ref.js"; import type { SessionCatalog, SessionCatalogHost, @@ -11,6 +12,7 @@ import { t } from "../i18n/index.ts"; import { formatUiError } from "../lib/format-error.ts"; import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts"; import { shouldHandleNavigationClick } from "../lib/navigation-click.ts"; +import { createOverflowFadeRef } from "../lib/overflow-fade.ts"; import type { CatalogSessionKey } from "../lib/sessions/catalog-key.ts"; import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts"; import { @@ -483,7 +485,9 @@ function renderCatalogSessionRow( > - ${label} + ${label} ${renderSessionRowBadges({ diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index ceb1838ce66f..9c79fb468a9b 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -1,6 +1,7 @@ import { html, nothing, type TemplateResult } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { keyed } from "lit/directives/keyed.js"; +import { ref } from "lit/directives/ref.js"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { NavigationRouteId } from "../app-navigation.ts"; import { sessionHasPendingApproval } from "../app/approval-presentation.ts"; @@ -9,8 +10,8 @@ import { resolveControlUiAuthCandidates } from "../app/control-ui-auth.ts"; import { t } from "../i18n/index.ts"; import { sessionHasBoard } from "../lib/board/provider.ts"; import { formatDurationCompact } from "../lib/format.ts"; -import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts"; import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts"; +import { createOverflowFadeRef } from "../lib/overflow-fade.ts"; import { projectPresencePayload } from "../lib/presence-users.ts"; import { writeSessionDragData } from "../lib/sessions/drag.ts"; import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; @@ -287,8 +288,6 @@ export function renderRecentSession(params: { }} @contextmenu=${openMenuFromEvent} @keydown=${openMenuFromEvent} - @mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)} - @mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)} > - ${session.archived - ? html`${icons.archive}` - : nothing}${session.forkSource - ? html`${icons.gitFork}` - : nothing}${label}${session.archived + ? html`${icons.archive}` + : nothing}${session.forkSource + ? html`${icons.gitFork}` + : nothing}${label} ${renderSidebarSessionSubtitle({ subtitle, narration })} @@ -446,7 +447,7 @@ export function renderRecentSession(params: { `; - // Marquee state mutates the row DOM; keying prevents cross-session reuse. + // The overflow ref owns per-title measurement state, so DOM cannot be reused across sessions. return keyed(session.key, row); } diff --git a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts index cc0e926faaed..0449109a0491 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -7,7 +7,6 @@ import { createChatFlowE2eSuite, expectDefined, installMockGateway, - pauseVirtualClock, requireRecord, } from "./chat-flow.test-support.ts"; @@ -226,14 +225,13 @@ suite.define(() => { } }); - it("keeps long sidebar labels clipped after a session switch", async () => { + it("reveals only clipped sidebar titles without moving row chrome", async () => { const context = await suite.newBrowserContext({ locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1280 }, }); const page = await context.newPage(); - await page.clock.install(); const sessions = chatSessionListResponse(); const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture"); const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture"); @@ -242,6 +240,8 @@ suite.define(() => { "Review and repair the intentionally overlong sidebar session title before navigation ".repeat( 4, ); + secondSession.hasAutomation = true; + secondSession.pinned = true; await installMockGateway(page, { methodResponses: { "sessions.list": sessions }, sessionKey: "agent:main:session-a", @@ -252,51 +252,98 @@ suite.define(() => { const recentRow = page.locator( '.sidebar-recent-session[data-session-key="agent:main:session-b"]', ); + const shortRow = page.locator( + '.sidebar-recent-session[data-session-key="agent:main:session-a"]', + ); const recentLabel = recentRow.locator(".sidebar-recent-session__name"); + const recentContent = recentLabel.locator(".sidebar-recent-session__name-content"); await recentLabel.waitFor({ state: "visible", timeout: 10_000 }); - const layout = await recentLabel.evaluate((label) => ({ - clientWidth: label.clientWidth, - linkWidth: label.parentElement?.clientWidth ?? 0, - rowWidth: label.closest(".sidebar-recent-session")?.clientWidth ?? 0, - scrollWidth: label.scrollWidth, - text: label.textContent, - })); - expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth); + await shortRow.locator(".sidebar-recent-session__name").waitFor({ state: "visible" }); + await expect + .poll(() => page.locator(".shell").evaluate((shell) => getComputedStyle(shell).transform)) + .toBe("none"); - // Freeze the clock so the 500ms hover-intent delay elapses only via - // runFor; a ticking clock let slow runners start the marquee before the - // "not yet scrolling" asserts below. - await pauseVirtualClock(page); - await recentRow.dispatchEvent("mouseenter"); - await page.clock.runFor(250); - expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain( - "hover-marquee--scrolling", + const shortTitle = shortRow.locator(".sidebar-recent-session__name"); + const shortContent = shortTitle.locator(".sidebar-recent-session__name-content"); + const shortTransform = await shortContent.evaluate( + (content) => getComputedStyle(content).transform, ); - await recentRow.dispatchEvent("mouseleave"); - // 250 + 300 exceeds the hover delay: only the leave-cancel keeps it off. - await page.clock.runFor(300); - expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain( - "hover-marquee--scrolling", + expect(await shortTitle.getAttribute("data-overflow-fade")).toBeNull(); + expect(await shortTitle.getAttribute("data-overflow-reveal")).toBeNull(); + await shortRow.hover(); + expect(await shortContent.evaluate((content) => getComputedStyle(content).transform)).toBe( + shortTransform, ); - await recentRow.dispatchEvent("mouseenter"); - await page.clock.runFor(500); + + const readLongTitleState = () => + recentRow.evaluate((row) => { + const rect = (selector: string) => { + const element = row.querySelector(selector); + if (!element) { + throw new Error(`Missing clipped-title fixture ${selector}`); + } + const box = element.getBoundingClientRect(); + return { bottom: box.bottom, left: box.left, right: box.right, top: box.top }; + }; + const title = row.querySelector(".sidebar-recent-session__name"); + const content = row.querySelector(".sidebar-recent-session__name-content"); + if (!title || !content) { + throw new Error("Missing clipped-title content"); + } + return { + badge: rect(".session-row-badges"), + contentTransform: getComputedStyle(content).transform, + fade: title.hasAttribute("data-overflow-fade"), + menu: rect("[data-session-menu]"), + pin: rect("[data-sidebar-session-pin]"), + reveal: title.hasAttribute("data-overflow-reveal"), + revealTranslate: title.style.getPropertyValue("--overflow-reveal-translate"), + title: rect(".sidebar-recent-session__name"), + titleClientWidth: title.clientWidth, + titleScrollWidth: content.scrollWidth, + }; + }); + + const resting = await readLongTitleState(); + expect(resting.titleScrollWidth).toBeGreaterThan(resting.titleClientWidth); + expect(resting.fade).toBe(true); + expect(resting.reveal).toBe(true); + expect(Number.parseFloat(resting.revealTranslate)).toBeLessThan(0); + + await recentRow.hover(); await expect - .poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 }) - .toContain("hover-marquee--scrolling"); - // Resume real time: the snap-back below is a compositor-driven CSS - // transition, not a fake-timer callback. - await page.clock.resume(); - await recentRow.dispatchEvent("mouseleave"); + .poll(() => recentContent.evaluate((content) => getComputedStyle(content).transform)) + .not.toBe(resting.contentTransform); + const hovered = await readLongTitleState(); + expect({ + badge: hovered.badge, + menu: hovered.menu, + pin: hovered.pin, + title: hovered.title, + }).toEqual({ + badge: resting.badge, + menu: resting.menu, + pin: resting.pin, + title: resting.title, + }); + + await recentRow.locator("a.sidebar-recent-session__link").focus(); + await page.mouse.move(1_000, 100); await expect - .poll( - () => - recentLabel.evaluate((label) => ({ - textIndent: getComputedStyle(label).textIndent, - textOverflow: getComputedStyle(label).textOverflow, - })), - { timeout: 1_500 }, - ) - .toEqual({ textIndent: "0px", textOverflow: "ellipsis" }); + .poll(() => recentContent.evaluate((content) => getComputedStyle(content).transform)) + .not.toBe(resting.contentTransform); + const focused = await readLongTitleState(); + expect({ + badge: focused.badge, + menu: focused.menu, + pin: focused.pin, + title: focused.title, + }).toEqual({ + badge: resting.badge, + menu: resting.menu, + pin: resting.pin, + title: resting.title, + }); await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", { button: 0, @@ -308,12 +355,13 @@ suite.define(() => { const activeRow = page.locator( '.sidebar-recent-session[data-session-key="agent:main:session-b"]', ); - expect( - await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({ - textIndent: getComputedStyle(label).textIndent, - textOverflow: getComputedStyle(label).textOverflow, - })), - ).toEqual({ textIndent: "0px", textOverflow: "ellipsis" }); + await expect + .poll(() => + activeRow + .locator(".sidebar-recent-session__name") + .evaluate((title) => title.hasAttribute("data-overflow-fade")), + ) + .toBe(true); } finally { await suite.closeBrowserContext(context); } diff --git a/ui/src/lib/hover-marquee.test.ts b/ui/src/lib/hover-marquee.test.ts deleted file mode 100644 index 237ba54deedd..000000000000 --- a/ui/src/lib/hover-marquee.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { startHoverMarquee, stopHoverMarquee } from "./hover-marquee.ts"; - -function buildRow(params: { textWidth: number; labelWidth: number }) { - const row = document.createElement("div"); - const label = document.createElement("span"); - label.className = "hover-marquee"; - label.textContent = "Fix stale iMessage group-allowlist warning copy"; - row.append(label); - document.body.append(row); - Object.defineProperty(label, "clientWidth", { value: params.labelWidth }); - Object.defineProperty(label, "scrollWidth", { value: params.textWidth }); - return { row, label }; -} - -describe("hover marquee", () => { - beforeEach(() => vi.useFakeTimers()); - - afterEach(() => { - vi.useRealTimers(); - document.body.replaceChildren(); - }); - - it("waits before scrolling overflowing labels by the clipped distance", () => { - const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 }); - startHoverMarquee(row); - expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px"); - expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms"); - vi.advanceTimersByTime(499); - expect(label.classList.contains("hover-marquee--scrolling")).toBe(false); - vi.advanceTimersByTime(1); - expect(label.classList.contains("hover-marquee--scrolling")).toBe(true); - stopHoverMarquee(row); - expect(label.classList.contains("hover-marquee--scrolling")).toBe(false); - }); - - it("cancels the delayed scroll when hover ends early", () => { - const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 }); - startHoverMarquee(row); - vi.advanceTimersByTime(250); - stopHoverMarquee(row); - vi.advanceTimersByTime(250); - expect(label.classList.contains("hover-marquee--scrolling")).toBe(false); - }); - - it("keeps short scroll distances readable with a minimum duration", () => { - const { row, label } = buildRow({ textWidth: 190, labelWidth: 180 }); - startHoverMarquee(row); - expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-10px"); - expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("300ms"); - }); - - it("leaves labels that fit untouched", () => { - const { row, label } = buildRow({ textWidth: 120, labelWidth: 180 }); - startHoverMarquee(row); - expect(label.classList.contains("hover-marquee--scrolling")).toBe(false); - expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe(""); - }); - - it("ignores hosts without a marquee label", () => { - const row = document.createElement("div"); - expect(() => { - startHoverMarquee(row); - stopHoverMarquee(row); - }).not.toThrow(); - }); -}); diff --git a/ui/src/lib/hover-marquee.ts b/ui/src/lib/hover-marquee.ts deleted file mode 100644 index 8d3599342ca0..000000000000 --- a/ui/src/lib/hover-marquee.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Hover marquee for truncated single-line labels: on pointer enter, animate -// text-indent to slide the clipped tail into view; on leave, the base -// transition in styles/components.css (.hover-marquee) snaps it back quickly. -// text-indent (not an inner transform wrapper) because text-overflow renders -// no ellipsis for atomic inline children, which would lose the resting "…". -const MARQUEE_SPEED_PX_PER_SEC = 80; -const MARQUEE_MIN_DURATION_MS = 300; -const MARQUEE_HOVER_DELAY_MS = 500; -const pendingMarquees = new WeakMap(); - -function findMarqueeLabel(host: HTMLElement): HTMLElement | null { - return host.classList.contains("hover-marquee") - ? host - : host.querySelector(".hover-marquee"); -} - -function clearPendingMarquee(label: HTMLElement): void { - const pending = pendingMarquees.get(label); - if (pending === undefined) { - return; - } - window.clearTimeout(pending); - pendingMarquees.delete(label); -} - -export function startHoverMarquee(host: HTMLElement): void { - const label = findMarqueeLabel(host); - if (!label || label.classList.contains("hover-marquee--scrolling")) { - return; - } - clearPendingMarquee(label); - // Measure at hover time: labels resize with the sidebar and with hover-only - // row actions, so a cached width would drift. A negative mid-transition - // indent (re-hover while snapping back) shrinks scrollWidth; add it back. - const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0; - const shift = label.scrollWidth - indent - label.clientWidth; - if (shift <= 1) { - return; - } - const durationMs = Math.max( - MARQUEE_MIN_DURATION_MS, - Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000), - ); - label.style.setProperty("--hover-marquee-shift", `${-shift}px`); - label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`); - // Keep quick pointer passes quiet; leaving before the timer fires cancels it. - pendingMarquees.set( - label, - window.setTimeout(() => { - pendingMarquees.delete(label); - label.classList.add("hover-marquee--scrolling"); - }, MARQUEE_HOVER_DELAY_MS), - ); -} - -export function stopHoverMarquee(host: HTMLElement): void { - const label = findMarqueeLabel(host); - if (!label) { - return; - } - clearPendingMarquee(label); - label.classList.remove("hover-marquee--scrolling"); -} diff --git a/ui/src/lib/overflow-fade.test.ts b/ui/src/lib/overflow-fade.test.ts new file mode 100644 index 000000000000..8402d54d0635 --- /dev/null +++ b/ui/src/lib/overflow-fade.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createOverflowFadeRef } from "./overflow-fade.ts"; + +function buildRow(params: { textWidth: number; titleWidth: number; direction?: "ltr" | "rtl" }) { + const row = document.createElement("div"); + const title = document.createElement("span"); + title.className = "sidebar-recent-session__name"; + title.style.direction = params.direction ?? "ltr"; + const content = document.createElement("span"); + content.className = "sidebar-recent-session__name-content"; + content.textContent = "Fix stale iMessage group-allowlist warning copy"; + const badge = document.createElement("span"); + badge.className = "session-row-badge"; + title.append(content); + row.append(title, badge); + document.body.append(row); + Object.defineProperty(title, "clientWidth", { value: params.titleWidth }); + Object.defineProperty(content, "scrollWidth", { value: params.textWidth }); + return { row, title, content, badge }; +} + +describe("overflow fade", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("marks only genuinely clipped titles for a resting fade", () => { + const clipped = buildRow({ textWidth: 320, titleWidth: 180 }); + createOverflowFadeRef()(clipped.title); + expect(clipped.title.hasAttribute("data-overflow-fade")).toBe(true); + + const fitting = buildRow({ textWidth: 120, titleWidth: 180 }); + createOverflowFadeRef()(fitting.title); + expect(fitting.title.hasAttribute("data-overflow-fade")).toBe(false); + }); + + it("reveals only the hidden tail without changing badges", () => { + const { title, badge } = buildRow({ textWidth: 320, titleWidth: 180 }); + const badgeBefore = badge.outerHTML; + + createOverflowFadeRef()(title); + + expect(title.hasAttribute("data-overflow-reveal")).toBe(true); + expect(title.style.getPropertyValue("--overflow-reveal-translate")).toBe("-140px"); + expect(title.style.getPropertyValue("--overflow-reveal-duration")).toBe("2240ms"); + expect(badge.outerHTML).toBe(badgeBefore); + }); + + it("leaves fitting titles untouched", () => { + const { title } = buildRow({ textWidth: 120, titleWidth: 180 }); + createOverflowFadeRef()(title); + expect(title.hasAttribute("data-overflow-reveal")).toBe(false); + expect(title.style.getPropertyValue("--overflow-reveal-translate")).toBe(""); + }); + + it("bounds reveal duration and reverses travel for RTL", () => { + const short = buildRow({ textWidth: 190, titleWidth: 180 }); + createOverflowFadeRef()(short.title); + expect(short.title.style.getPropertyValue("--overflow-reveal-duration")).toBe("1200ms"); + + const long = buildRow({ textWidth: 900, titleWidth: 180, direction: "rtl" }); + createOverflowFadeRef()(long.title); + expect(long.title.style.getPropertyValue("--overflow-reveal-translate")).toBe("720px"); + expect(long.title.style.getPropertyValue("--overflow-reveal-duration")).toBe("8000ms"); + }); + + it("ignores detached refs", () => { + expect(() => createOverflowFadeRef()(undefined)).not.toThrow(); + }); +}); diff --git a/ui/src/lib/overflow-fade.ts b/ui/src/lib/overflow-fade.ts new file mode 100644 index 000000000000..2772555fd908 --- /dev/null +++ b/ui/src/lib/overflow-fade.ts @@ -0,0 +1,45 @@ +/** Ref callback that marks single-line text only while it is genuinely clipped. */ +export function createOverflowFadeRef() { + let target: HTMLElement | null = null; + let observer: ResizeObserver | null = null; + + const sync = () => { + if (!target) { + return; + } + const content = target.querySelector(".sidebar-recent-session__name-content"); + const contentWidth = content?.scrollWidth ?? target.scrollWidth; + const revealDistance = Math.max(0, contentWidth - target.clientWidth); + target.toggleAttribute("data-overflow-fade", revealDistance > 1); + target.toggleAttribute("data-overflow-reveal", revealDistance > 1); + if (revealDistance <= 1) { + target.style.removeProperty("--overflow-reveal-translate"); + target.style.removeProperty("--overflow-reveal-duration"); + return; + } + const direction = getComputedStyle(target).direction === "rtl" ? 1 : -1; + target.style.setProperty("--overflow-reveal-translate", `${direction * revealDistance}px`); + const revealDuration = Math.min(8_000, Math.max(1_200, revealDistance * 16)); + target.style.setProperty("--overflow-reveal-duration", `${revealDuration}ms`); + }; + + return (element?: Element) => { + const next = element instanceof HTMLElement ? element : null; + if (next === target) { + sync(); + return; + } + observer?.disconnect(); + target = next; + observer = null; + if (!target) { + return; + } + sync(); + if (typeof ResizeObserver !== "undefined") { + observer = new ResizeObserver(sync); + observer.observe(target); + } + queueMicrotask(sync); + }; +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 46560a1c527c..10890ce9851f 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -5963,40 +5963,6 @@ td.data-table-key-col { } } -/* =========================================== - Hover Marquee - =========================================== */ - -/* Truncated single-line labels that slide on hover to reveal the clipped - tail. lib/hover-marquee.ts measures the overflow and sets the custom - properties; without it the label stays a plain ellipsized span. Animates - text-indent so the resting ellipsis keeps rendering (text-overflow shows - no "…" for atomic inline children, ruling out a transformed wrapper). */ -.hover-marquee { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: text-indent 180ms ease-out; -} - -.hover-marquee--scrolling { - text-overflow: clip; - text-indent: var(--hover-marquee-shift, 0); - transition-duration: var(--hover-marquee-duration, 600ms); - transition-timing-function: linear; -} - -@media (prefers-reduced-motion: reduce) { - .hover-marquee { - transition: none; - } - - .hover-marquee--scrolling { - text-overflow: ellipsis; - text-indent: 0; - } -} - /* ---- Plugin tab embeds (plugin-served panels) ---- */ .plugin-tab-embed { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index ff0016d87dea..1b76032fe25c 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2291,6 +2291,53 @@ body.update-dialog-open .sidebar-update-card__status { color: var(--text); } +.sidebar-recent-session__name-content { + display: inline-block; + min-width: max-content; + transform: translateX(0); +} + +/* The mask exists only when the measured title is clipped; short titles keep + their full opacity. Revealing transforms only the title content, so row + controls and badges retain their positions. */ +.sidebar-recent-session__name[data-overflow-fade] { + -webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 10px), transparent 100%); + mask-image: linear-gradient(to right, black 0, black calc(100% - 10px), transparent 100%); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + text-overflow: clip; +} + +.sidebar-recent-session__name[data-overflow-fade]:dir(rtl) { + -webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 10px), transparent 100%); + mask-image: linear-gradient(to left, black 0, black calc(100% - 10px), transparent 100%); +} + +.session-row-host:is(:hover, :focus-within) .sidebar-recent-session__name[data-overflow-reveal] { + -webkit-mask-image: linear-gradient(to right, transparent 0, black 10px, black 100%); + mask-image: linear-gradient(to right, transparent 0, black 10px, black 100%); +} + +.session-row-host:is(:hover, :focus-within) + .sidebar-recent-session__name[data-overflow-reveal]:dir(rtl) { + -webkit-mask-image: linear-gradient(to left, transparent 0, black 10px, black 100%); + mask-image: linear-gradient(to left, transparent 0, black 10px, black 100%); +} + +.session-row-host:is(:hover, :focus-within) + .sidebar-recent-session__name[data-overflow-reveal] + .sidebar-recent-session__name-content { + transform: translateX(var(--overflow-reveal-translate, 0)); + transition: transform var(--overflow-reveal-duration, 900ms) linear 180ms; +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-recent-session__name-content { + transform: none !important; + transition: none !important; + } +} + .sidebar-recent-session--child { min-height: 30px; } @@ -2375,9 +2422,9 @@ body.update-dialog-open .sidebar-update-card__status { right: calc(var(--sidebar-child-session-toggle-width) + 4px); } -/* Centered actions overlap both lines, so the full text column yields their width. */ -.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within) - .sidebar-recent-session__text { +/* Centered actions overlap both lines, so reserve their width in every state. + Hover and keyboard focus then reveal chrome without moving titles or badges. */ +.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text { padding-right: 52px; } @@ -2387,13 +2434,6 @@ body.update-dialog-open .sidebar-update-card__status { pointer-events: none; } -/* Touch keeps the actions permanently visible beside the persistent endcap. */ -@media (hover: none), (pointer: coarse) { - .sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text { - padding-right: 52px; - } -} - .nav-collapse-toggle { width: 36px; height: 36px;