diff --git a/src/components/ancestor-lines.tsx b/src/components/ancestor-lines.tsx
index ff4ea2d..a94b975 100644
--- a/src/components/ancestor-lines.tsx
+++ b/src/components/ancestor-lines.tsx
@@ -40,6 +40,7 @@ export function AncestorLines({ ancestors, onToggle, lineWidth }: AncestorLinesP
onToggle={() => onToggle(ancestor.uri)}
authorName={ancestor.authorName}
replyCount={ancestor.replyCount}
+ ancestorUri={ancestor.uri}
opacity={opacity}
showChevron={false}
width={lineWidth}
diff --git a/src/components/reply-branch.tsx b/src/components/reply-branch.tsx
index 6cf0353..0e6ef91 100644
--- a/src/components/reply-branch.tsx
+++ b/src/components/reply-branch.tsx
@@ -147,6 +147,7 @@ export function ReplyBranch({
onToggle={() => toggleCollapse(node.reply.uri)}
authorName={authorName}
replyCount={descendantCount}
+ ancestorUri={node.reply.uri}
opacity={1}
showChevron={showChevron}
width={indentStep}
diff --git a/src/components/reply-thread.tsx b/src/components/reply-thread.tsx
index 13f2cab..49ce0cb 100644
--- a/src/components/reply-thread.tsx
+++ b/src/components/reply-thread.tsx
@@ -13,6 +13,7 @@ import type { Reply } from '@/lib/api/types'
import { cn } from '@/lib/utils'
import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree'
import { useThreadIndent } from '@/hooks/use-thread-indent'
+import { ThreadHoverProvider } from '@/context/thread-hover-context'
import { ReplyBranch } from './reply-branch'
interface ReplyThreadProps {
@@ -58,17 +59,19 @@ export function ReplyThread({
No replies yet. Be the first to respond!
) : (
-
+
+
+
)}
)
diff --git a/src/components/thread-line.test.tsx b/src/components/thread-line.test.tsx
index 9840b6d..e1c876d 100644
--- a/src/components/thread-line.test.tsx
+++ b/src/components/thread-line.test.tsx
@@ -2,33 +2,43 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe } from 'vitest-axe'
+import { ThreadHoverProvider } from '@/context/thread-hover-context'
import { ThreadLine } from './thread-line'
+function renderWithProvider(ui: React.ReactElement) {
+ return render({ui})
+}
+
describe('ThreadLine', () => {
const defaultProps = {
expanded: true,
onToggle: vi.fn(),
authorName: 'Alex',
replyCount: 5,
+ ancestorUri: 'at://test/line1',
opacity: 1,
showChevron: true,
}
it('renders as a button', () => {
- render()
+ renderWithProvider()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('has aria-expanded matching expanded prop', () => {
- const { rerender } = render()
+ const { rerender } = renderWithProvider()
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')
- rerender()
+ rerender(
+
+
+
+ )
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
})
it('has descriptive aria-label including author and reply count', () => {
- render()
+ renderWithProvider()
const label = screen.getByRole('button').getAttribute('aria-label')!
expect(label).toContain('Alex')
expect(label).toContain('5')
@@ -37,7 +47,7 @@ describe('ThreadLine', () => {
it('calls onToggle when clicked', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
- render()
+ renderWithProvider()
await user.click(screen.getByRole('button'))
expect(onToggle).toHaveBeenCalledTimes(1)
})
@@ -50,25 +60,25 @@ describe('ThreadLine', () => {
})
it('does not render chevron icon when showChevron is false', () => {
- const { container } = render()
+ const { container } = renderWithProvider()
expect(container.querySelector('svg')).not.toBeInTheDocument()
})
it('applies opacity style to the line', () => {
- const { container } = render()
+ const { container } = renderWithProvider()
const line = container.querySelector('[aria-hidden="true"]')
expect(line).toBeInTheDocument()
expect(line!.getAttribute('style')).toContain('opacity')
})
it('applies width from prop', () => {
- const { container } = render()
+ const { container } = renderWithProvider()
const button = container.querySelector('button')!
expect(button.style.width).toBe('22px')
})
it('passes axe accessibility check', async () => {
- const { container } = render()
+ const { container } = renderWithProvider()
const results = await axe(container)
expect(results).toHaveNoViolations()
})
diff --git a/src/components/thread-line.tsx b/src/components/thread-line.tsx
index 3499a4b..f4a0d84 100644
--- a/src/components/thread-line.tsx
+++ b/src/components/thread-line.tsx
@@ -1,17 +1,21 @@
/**
* ThreadLine - Clickable vertical line with chevron for collapsing thread branches.
- * Visual width: 2px. Tap target: 44px minimum for accessibility.
* Chevron: CaretDown (expanded) / CaretRight (collapsed). Hidden on mobile.
* Opacity: controlled by parent to create depth-fade effect for ancestor lines.
+ * Hover: coordinated via ThreadHoverContext so all segments of the same
+ * ancestor line highlight together across the full thread height.
*/
import { CaretDown, CaretRight } from '@phosphor-icons/react'
+import { useThreadHover } from '@/context/thread-hover-context'
interface ThreadLineProps {
expanded: boolean
onToggle: () => void
authorName: string
replyCount: number
+ /** URI identifying which ancestor this line belongs to. Used for coordinated hover. */
+ ancestorUri: string
opacity?: number
showChevron?: boolean
/** Width in pixels. Matches the indent step so lines ARE the indentation. */
@@ -23,18 +27,27 @@ export function ThreadLine({
onToggle,
authorName,
replyCount,
+ ancestorUri,
opacity = 1,
showChevron = true,
width = 22,
}: ThreadLineProps) {
+ const { hoveredUri, setHovered } = useThreadHover()
+ const isHighlighted = hoveredUri === ancestorUri
+
const label = expanded
? `Collapse thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
: `Expand thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
+ const chevronColor = isHighlighted ? 'text-accent-foreground' : 'text-muted-foreground'
+ const lineColor = isHighlighted ? 'bg-accent-foreground' : 'bg-border'
+
return (