🗺️ refactor: Light Up In-Viewport Ribs at Rest in Message Nav (#14041)

At rest (no hover/focus) the rail now reads like a minimap: only the
in-viewport message ribs are at full opacity while the rest fade to 40%,
updating live as you scroll. Replaces the blanket nav opacity-30 with
per-rib opacity driven by the existing viewport-visibility highlight, so
hover/focus still brings every rib to full opacity for the fisheye.
This commit is contained in:
Danny Avila 2026-06-30 16:49:01 -04:00 • committed by GitHub
parent dd8a4558f1
commit 6523a5add6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 28 additions and 6 deletions

View file

@ -130,10 +130,13 @@ export function magnifyFalloff(distance: number, influence: number): number {
}
const indicatorButtonClasses = cn(
'flex h-1.5 w-full items-center justify-end rounded-sm',
'flex h-1.5 w-full items-center justify-end rounded-sm transition-opacity duration-300',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
);
const dimIndicatorClasses =
'opacity-40 group-hover/nav:opacity-100 group-focus-within/nav:opacity-100';
const MessageIndicator = memo(function MessageIndicator({
entry,
isHighlighted,
@ -155,7 +158,7 @@ const MessageIndicator = memo(function MessageIndicator({
e.stopPropagation();
onSelect(entry.id);
}}
className={indicatorButtonClasses}
className={cn(indicatorButtonClasses, isHighlighted ? 'opacity-100' : dimIndicatorClasses)}
aria-label={label}
aria-current={isCurrent ? 'true' : undefined}
data-msg-id={entry.id}
@ -172,8 +175,9 @@ const MessageIndicator = memo(function MessageIndicator({
});
const chevronButtonClasses = cn(
'rounded-md p-0.5 text-text-tertiary transition-colors',
'group-hover/nav:text-text-secondary group-focus-within/nav:text-text-secondary',
'rounded-md p-0.5 text-text-tertiary opacity-40 transition-[color,opacity] duration-300',
'group-hover/nav:text-text-secondary group-hover/nav:opacity-100',
'group-focus-within/nav:text-text-secondary group-focus-within/nav:opacity-100',
'group-hover/nav:hover:text-text-primary',
'group-hover/nav:disabled:opacity-30 group-focus-within/nav:disabled:opacity-30',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
@ -1113,8 +1117,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
className={cn(
'group/nav absolute right-2 top-1/2 z-40 hidden max-h-[min(24rem,calc(100%-2rem))]',
'-translate-y-1/2 flex-col items-end gap-1.5 px-1.5 py-2 md:flex',
'opacity-30 transition-opacity duration-300',
'focus-within:opacity-100 hover:opacity-100',
)}
>
<button

View file

@ -303,6 +303,26 @@ describe('MessageNav', () => {
expect(userLine?.className).toContain('w-4');
expect(assistantLine?.className).toContain('w-4');
});
it('lights up only the in-viewport ribs at rest (no hover)', () => {
const messages = [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const io = MockIntersectionObserver.last();
act(() => {
io!.trigger([{ target: document.getElementById('a')!, isIntersecting: true }]);
jest.advanceTimersByTime(32);
});
const ribA = container.querySelector('[data-msg-id="a"]') as HTMLElement;
const ribB = container.querySelector('[data-msg-id="b"]') as HTMLElement;
expect(ribA.className).toContain('opacity-100');
expect(ribA.className).not.toContain('opacity-40');
expect(ribB.className).toContain('opacity-40');
});
});
describe('preview text', () => {