feat(desktop): marquee clipped card titles on hover
Hovering a truncated inbox-card title glides the clipped tail into view — one direction at constant speed, a short hold at each end, then a snap back to the start. Overflow is measured on pointerenter and the animation arms only when the text actually clips, so short titles never wiggle. State lives in DOM attributes and CSS variables, so a hover never re-renders the memoized row, and the blanket reduced-motion override already disables it.
This commit is contained in:
parent
45f663b746
commit
c96e0daba5
|
|
@ -70,6 +70,31 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
|||
|
||||
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
|
||||
|
||||
// Hover marquee (card title): measure the actual overflow on pointerenter and
|
||||
// arm the CSS animation only when there is some — CSS can't detect overflow on
|
||||
// its own, and animating a non-overflowing title would wiggle for nothing.
|
||||
// Distance-proportional duration keeps the scroll speed constant across short
|
||||
// and long overflows. State lives in DOM attributes, not React state: hover
|
||||
// must not re-render a memoized row.
|
||||
const MARQUEE_PX_PER_SECOND = 80
|
||||
|
||||
function armMarquee(event: React.PointerEvent<HTMLElement>) {
|
||||
const el = event.currentTarget
|
||||
const distance = el.scrollWidth - el.clientWidth
|
||||
|
||||
if (distance > 2) {
|
||||
// The keyframes spend 65% of the cycle travelling (10%→75%); scale the
|
||||
// duration so the travel segment itself moves at the target speed.
|
||||
el.style.setProperty('--marquee-d', `${distance}px`)
|
||||
el.style.setProperty('--marquee-t', `${Math.max(1, distance / MARQUEE_PX_PER_SECOND / 0.65)}s`)
|
||||
el.dataset.marquee = 'true'
|
||||
}
|
||||
}
|
||||
|
||||
function disarmMarquee(event: React.PointerEvent<HTMLElement>) {
|
||||
delete event.currentTarget.dataset.marquee
|
||||
}
|
||||
|
||||
// The last thing in the trailing slot hands its place to the ⋯ button on hover,
|
||||
// and is never narrower than the button that has to cover it. A PR chip is the
|
||||
// exception while the pointer is on it: it's a link, and the kebab sits
|
||||
|
|
@ -374,34 +399,87 @@ function SidebarSessionRowImpl({
|
|||
onResume()
|
||||
}}
|
||||
>
|
||||
{reorderable ? (
|
||||
<SidebarRowGrab ariaLabel={handleLabel} dragging={dragging} dragHandleProps={dragHandleProps}>
|
||||
{lead ?? (
|
||||
<SessionStatusDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
session={session}
|
||||
storedSessionId={session.id}
|
||||
/>
|
||||
)}
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className="overflow-hidden">
|
||||
{lead ?? <SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />}
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
<Tip label={r.handoffOrigin(handoffLabel)}>
|
||||
<PlatformAvatar
|
||||
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
|
||||
platformId={handoffSource}
|
||||
platformName={handoffLabel}
|
||||
/>
|
||||
</Tip>
|
||||
) : null}
|
||||
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</SidebarRowLabel>
|
||||
{(() => {
|
||||
const leadNode = reorderable ? (
|
||||
<SidebarRowGrab ariaLabel={handleLabel} dragging={dragging} dragHandleProps={dragHandleProps}>
|
||||
{lead ?? (
|
||||
<SessionStatusDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
session={session}
|
||||
storedSessionId={session.id}
|
||||
/>
|
||||
)}
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className="overflow-hidden">
|
||||
{lead ?? <SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />}
|
||||
</SidebarRowLead>
|
||||
)
|
||||
|
||||
const handoffBadge =
|
||||
handoffSource && handoffLabel ? (
|
||||
<Tip label={r.handoffOrigin(handoffLabel)}>
|
||||
<PlatformAvatar
|
||||
className="-mt-px size-4 shrink-0 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
|
||||
platformId={handoffSource}
|
||||
platformName={handoffLabel}
|
||||
/>
|
||||
</Tip>
|
||||
) : null
|
||||
|
||||
if (!card) {
|
||||
return (
|
||||
<>
|
||||
{leadNode}
|
||||
{handoffBadge}
|
||||
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</SidebarRowLabel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header row — ONE div: dot, context, then the age/kebab
|
||||
cluster in flow at its right edge. Keeping the cluster
|
||||
inside this line (instead of the shell's full-height side
|
||||
column) means title/preview/meta below span the card's
|
||||
entire width — nothing truncates against the kebab. */}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{leadNode}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] leading-none text-(--ui-text-tertiary)">
|
||||
{context}
|
||||
</span>
|
||||
{handoffBadge}
|
||||
{actionsNode}
|
||||
</div>
|
||||
{/* Title + preview: ONE grouped cell with its own tight
|
||||
internal gap — it does not inherit the card's rhythm. */}
|
||||
<div className="-mt-[0.2em] flex min-w-0 flex-col gap-[0.3rem]">
|
||||
<SidebarRowLabel
|
||||
className="hover-marquee text-[0.8125rem] leading-none font-medium text-(--ui-text-primary) group-data-[working=true]:text-foreground"
|
||||
onPointerEnter={armMarquee}
|
||||
onPointerLeave={disarmMarquee}
|
||||
>
|
||||
<span className="hover-marquee-inner">{title}</span>
|
||||
</SidebarRowLabel>
|
||||
{session.preview && rowMeta.includes('preview') ? (
|
||||
<span className="min-w-0 truncate text-[0.625rem] leading-none text-(--ui-text-quaternary)">
|
||||
{session.preview}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{model || size ? (
|
||||
<span className="flex min-w-0 items-baseline gap-2 text-[0.625rem] leading-none text-(--ui-text-tertiary)">
|
||||
{model ? <span className="min-w-0 truncate">{model}</span> : null}
|
||||
{size ? <span className="shrink-0 tabular-nums">{size}</span> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</SidebarRowBody>
|
||||
</SidebarRowShell>
|
||||
</SessionContextMenu>
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,35 @@ body.guest-pointer-lock :is(webview, iframe) {
|
|||
background-color: color-mix(in srgb, var(--dt-midground) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Hover marquee: a truncated line that scrolls its overflow into view while
|
||||
hovered. Opt-in via .hover-marquee + an inner .hover-marquee-inner; the
|
||||
JS side measures the overflow on pointerenter and sets --marquee-d (and
|
||||
data-marquee) only when the text actually overflows, so short titles
|
||||
keep their plain ellipsis and never wiggle. Alternate direction reads
|
||||
back to the start; the 300ms delay keeps casual pointer passes still. */
|
||||
.hover-marquee[data-marquee='true'] {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.hover-marquee[data-marquee='true'] > .hover-marquee-inner {
|
||||
display: inline-block;
|
||||
animation: hover-marquee var(--marquee-t, 3s) linear infinite;
|
||||
}
|
||||
|
||||
/* One direction, Spotify-style: scroll the tail in, hold, snap back, hold.
|
||||
The percentages pin the two holds; the travel occupies the middle. */
|
||||
@keyframes hover-marquee {
|
||||
0%,
|
||||
10% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
75%,
|
||||
100% {
|
||||
transform: translateX(calc(-1 * var(--marquee-d, 0px)));
|
||||
}
|
||||
}
|
||||
|
||||
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
|
||||
render under document.body, outside the `.scrollbar-dt` scope on
|
||||
#root. Same visual treatment, applied directly to the overlay
|
||||
|
|
|
|||
Loading…
Reference in New Issue