+
))}
- {hasChildren &&
- isCollapsed &&
- (() => {
- const totalHidden = countDescendants(node)
- return (
-
- {totalHidden} {totalHidden === 1 ? 'reply' : 'replies'} hidden
-
- )
- })()}
)
})}
diff --git a/src/components/reply-thread.tsx b/src/components/reply-thread.tsx
index e50c62a..13f2cab 100644
--- a/src/components/reply-thread.tsx
+++ b/src/components/reply-thread.tsx
@@ -2,7 +2,7 @@
* ReplyThread - Displays a threaded tree of replies.
* Reconstructs tree from flat API response, assigns depth-first post numbers.
* Post numbers start at 2 (post #1 is the topic itself).
- * Responsive visual indent caps limit nesting on smaller screens.
+ * Uses viewport-aware indent steps for responsive nesting.
* @see specs/prd-web.md Section 4 (Topic Components)
*/
@@ -12,7 +12,7 @@ import { useMemo } from 'react'
import type { Reply } from '@/lib/api/types'
import { cn } from '@/lib/utils'
import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree'
-import { useVisualIndentCap } from '@/hooks/use-visual-indent-cap'
+import { useThreadIndent } from '@/hooks/use-thread-indent'
import { ReplyBranch } from './reply-branch'
interface ReplyThreadProps {
@@ -36,7 +36,7 @@ export function ReplyThread({
const heading =
replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies`
- const visualIndentCap = useVisualIndentCap()
+ const { indentStep, showChevron } = useThreadIndent()
const { tree, postNumberMap, allReplies } = useMemo(() => {
const builtTree = buildReplyTree(replies, topicUri)
@@ -63,8 +63,8 @@ export function ReplyThread({
postNumberMap={postNumberMap}
topicUri={topicUri}
allReplies={allReplies}
- visualIndentCap={visualIndentCap}
- currentVisualDepth={1}
+ indentStep={indentStep}
+ showChevron={showChevron}
onReply={onReply}
onDeleteReply={onDeleteReply}
currentUserDid={currentUserDid}
diff --git a/src/components/thread-line.test.tsx b/src/components/thread-line.test.tsx
index 5873309..29c5145 100644
--- a/src/components/thread-line.test.tsx
+++ b/src/components/thread-line.test.tsx
@@ -1,7 +1,3 @@
-/**
- * Tests for ThreadLine component.
- */
-
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -9,48 +5,70 @@ import { axe } from 'vitest-axe'
import { ThreadLine } from './thread-line'
describe('ThreadLine', () => {
+ const defaultProps = {
+ expanded: true,
+ onToggle: vi.fn(),
+ authorName: 'Alex',
+ replyCount: 5,
+ opacity: 1,
+ showChevron: true,
+ }
+
it('renders as a button', () => {
- render(
)
+ render(
)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('has aria-expanded matching expanded prop', () => {
- const { rerender } = render(
)
+ const { rerender } = render(
)
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')
- rerender(
)
+ rerender(
)
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
})
- it('has descriptive aria-label', () => {
- render(
)
- expect(screen.getByRole('button')).toHaveAttribute(
- 'aria-label',
- expect.stringContaining('Alex')
- )
+ it('has descriptive aria-label including author and reply count', () => {
+ render(
)
+ const label = screen.getByRole('button').getAttribute('aria-label')!
+ expect(label).toContain('Alex')
+ expect(label).toContain('5')
})
it('calls onToggle when clicked', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
- render(
)
+ render(
)
await user.click(screen.getByRole('button'))
expect(onToggle).toHaveBeenCalledTimes(1)
})
- it('has adequate tap target (min 44px width)', () => {
+ it('renders chevron icon when showChevron is true', () => {
const { container } = render(
-
+
)
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('does not render chevron icon when showChevron is false', () => {
+ const { container } = render(
)
+ expect(container.querySelector('svg')).not.toBeInTheDocument()
+ })
+
+ it('applies opacity style to the line', () => {
+ const { container } = render(
)
+ const line = container.querySelector('[aria-hidden="true"]')
+ expect(line).toBeInTheDocument()
+ expect(line!.getAttribute('style')).toContain('opacity')
+ })
+
+ it('has adequate tap target (min 44px)', () => {
+ const { container } = render(
)
const button = container.querySelector('button')!
- // The button should have min-width of 44px via class
- expect(button.className).toMatch(/min-w-\[44px\]|w-11/)
+ expect(button.className).toMatch(/min-w-\[44px\]/)
})
it('passes axe accessibility check', async () => {
- const { container } = render(
-
- )
+ const { container } = render(
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
diff --git a/src/components/thread-line.tsx b/src/components/thread-line.tsx
index dc85c4c..83ed711 100644
--- a/src/components/thread-line.tsx
+++ b/src/components/thread-line.tsx
@@ -1,16 +1,32 @@
/**
- * ThreadLine - Clickable vertical line for collapsing thread branches.
+ * 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.
*/
+import { CaretDown, CaretRight } from '@phosphor-icons/react'
+
interface ThreadLineProps {
expanded: boolean
onToggle: () => void
authorName: string
+ replyCount: number
+ opacity?: number
+ showChevron?: boolean
}
-export function ThreadLine({ expanded, onToggle, authorName }: ThreadLineProps) {
- const label = expanded ? `Collapse thread by ${authorName}` : `Expand thread by ${authorName}`
+export function ThreadLine({
+ expanded,
+ onToggle,
+ authorName,
+ replyCount,
+ opacity = 1,
+ showChevron = true,
+}: ThreadLineProps) {
+ const label = expanded
+ ? `Collapse thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
+ : `Expand thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`
return (
)
}
diff --git a/src/hooks/use-thread-indent.test.ts b/src/hooks/use-thread-indent.test.ts
new file mode 100644
index 0000000..4034738
--- /dev/null
+++ b/src/hooks/use-thread-indent.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useThreadIndent } from './use-thread-indent'
+
+vi.mock('./use-media-query', () => ({
+ useMediaQuery: vi.fn(),
+}))
+
+import { useMediaQuery } from './use-media-query'
+const mockUseMediaQuery = vi.mocked(useMediaQuery)
+
+describe('useThreadIndent', () => {
+ beforeEach(() => {
+ mockUseMediaQuery.mockReset()
+ })
+
+ it('returns desktop indent step for wide viewports', () => {
+ mockUseMediaQuery.mockImplementation((q: string) => {
+ if (q === '(min-width: 768px)') return true
+ if (q === '(min-width: 481px)') return true
+ return false
+ })
+ const { result } = renderHook(() => useThreadIndent())
+ expect(result.current.indentStep).toBe(22)
+ expect(result.current.showChevron).toBe(true)
+ })
+
+ it('returns tablet indent step for medium viewports', () => {
+ mockUseMediaQuery.mockImplementation((q: string) => {
+ if (q === '(min-width: 768px)') return false
+ if (q === '(min-width: 481px)') return true
+ return false
+ })
+ const { result } = renderHook(() => useThreadIndent())
+ expect(result.current.indentStep).toBe(16)
+ expect(result.current.showChevron).toBe(true)
+ })
+
+ it('returns mobile indent step and hides chevron for narrow viewports', () => {
+ mockUseMediaQuery.mockImplementation(() => false)
+ const { result } = renderHook(() => useThreadIndent())
+ expect(result.current.indentStep).toBe(8)
+ expect(result.current.showChevron).toBe(false)
+ })
+})
diff --git a/src/hooks/use-thread-indent.ts b/src/hooks/use-thread-indent.ts
new file mode 100644
index 0000000..2fb479f
--- /dev/null
+++ b/src/hooks/use-thread-indent.ts
@@ -0,0 +1,22 @@
+/**
+ * Returns thread indent configuration based on viewport width.
+ * - indentStep: pixels per indent level
+ * - showChevron: whether to render the chevron icon (hidden on mobile)
+ */
+
+import { INDENT_STEP } from '@/lib/threading-constants'
+import { useMediaQuery } from './use-media-query'
+
+interface ThreadIndent {
+ indentStep: number
+ showChevron: boolean
+}
+
+export function useThreadIndent(): ThreadIndent {
+ const isDesktop = useMediaQuery('(min-width: 768px)')
+ const isTablet = useMediaQuery('(min-width: 481px)')
+
+ if (isDesktop) return { indentStep: INDENT_STEP.desktop, showChevron: true }
+ if (isTablet) return { indentStep: INDENT_STEP.tablet, showChevron: true }
+ return { indentStep: INDENT_STEP.mobile, showChevron: false }
+}
diff --git a/src/hooks/use-visual-indent-cap.ts b/src/hooks/use-visual-indent-cap.ts
deleted file mode 100644
index 0e4f975..0000000
--- a/src/hooks/use-visual-indent-cap.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Returns the maximum visual indent level based on viewport width.
- * Desktop (>=768px): 4, Tablet (>=481px): 3, Mobile (<481px): 2.
- * Defaults to desktop value during SSR.
- */
-
-import { VISUAL_INDENT_CAPS } from '@/lib/threading-constants'
-import { useMediaQuery } from './use-media-query'
-
-export function useVisualIndentCap(): number {
- const isDesktop = useMediaQuery('(min-width: 768px)')
- const isTablet = useMediaQuery('(min-width: 481px)')
-
- if (isDesktop) return VISUAL_INDENT_CAPS.desktop
- if (isTablet) return VISUAL_INDENT_CAPS.tablet
- return VISUAL_INDENT_CAPS.mobile
-}
diff --git a/src/lib/threading-constants.ts b/src/lib/threading-constants.ts
index ebc08d0..e711154 100644
--- a/src/lib/threading-constants.ts
+++ b/src/lib/threading-constants.ts
@@ -1,9 +1,17 @@
export const MAX_REPLY_DEPTH_DEFAULT = 9999
-export const VISUAL_INDENT_CAPS = { desktop: 4, tablet: 3, mobile: 2 } as const
+export const VISUAL_INDENT_CAP = 10
+
+export const INDENT_STEP = { desktop: 22, tablet: 16, mobile: 8 } as const
export const DEFAULT_EXPANDED_LEVELS = 3
export const AUTO_COLLAPSE_SIBLING_THRESHOLD = 5
export const AUTO_COLLAPSE_SHOW_COUNT = 3
+
+/**
+ * Opacity values for ancestor thread lines.
+ * Index 0 = direct parent (rightmost line), higher indices = further ancestors.
+ */
+export const LINE_OPACITY = [1, 0.7, 0.5, 0.35, 0.25, 0.2, 0.15, 0.1, 0.1, 0.1] as const