diff --git a/.gitignore b/.gitignore index b26bf63..5827b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ # worktrees .worktrees +.claude/worktrees # debug npm-debug.log* diff --git a/src/components/ancestor-lines.test.tsx b/src/components/ancestor-lines.test.tsx new file mode 100644 index 0000000..0c3bd99 --- /dev/null +++ b/src/components/ancestor-lines.test.tsx @@ -0,0 +1,63 @@ +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 { AncestorLines } from './ancestor-lines' + +describe('AncestorLines', () => { + it('renders one button per ancestor', () => { + const ancestors = [ + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, + ] + render() + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + }) + + it('applies decreasing opacity from right to left', () => { + const ancestors = [ + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, + { uri: 'a3', authorName: 'Carol', replyCount: 1, expanded: true }, + ] + const { container } = render( + + ) + const lines = container.querySelectorAll('[aria-hidden="true"]') + expect(lines.length).toBeGreaterThan(0) + // Verify different opacity values exist + const opacities = Array.from(lines).map((l) => l.getAttribute('style')) + const uniqueOpacities = new Set(opacities) + expect(uniqueOpacities.size).toBeGreaterThan(1) + }) + + it('calls onToggle with correct uri when an ancestor line is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + const ancestors = [ + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, + ] + render() + const buttons = screen.getAllByRole('button') + await user.click(buttons[0]!) + expect(onToggle).toHaveBeenCalledWith('a1') + }) + + it('renders nothing when ancestors array is empty', () => { + const { container } = render( + + ) + expect(container.querySelector('button')).not.toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const ancestors = [{ uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }] + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/ancestor-lines.tsx b/src/components/ancestor-lines.tsx new file mode 100644 index 0000000..a714da0 --- /dev/null +++ b/src/components/ancestor-lines.tsx @@ -0,0 +1,48 @@ +/** + * AncestorLines - Renders vertical thread lines for all ancestor depths. + * Each line is an independent collapse control for that ancestor's sub-thread. + * Opacity fades from right (direct parent, full) to left (distant ancestor, dim). + * Chevrons are never shown on ancestor lines (only on the direct parent's ThreadLine). + */ + +import { LINE_OPACITY } from '@/lib/threading-constants' +import { ThreadLine } from './thread-line' + +export interface AncestorInfo { + uri: string + authorName: string + replyCount: number + expanded: boolean +} + +interface AncestorLinesProps { + /** Ancestors ordered from outermost (index 0) to innermost (last index). */ + ancestors: AncestorInfo[] + onToggle: (uri: string) => void + showChevron: boolean +} + +export function AncestorLines({ ancestors, onToggle }: AncestorLinesProps) { + if (ancestors.length === 0) return null + + return ( + <> + {ancestors.map((ancestor, index) => { + const distanceFromRight = ancestors.length - 1 - index + const opacity = LINE_OPACITY[distanceFromRight] ?? LINE_OPACITY[LINE_OPACITY.length - 1]! + + return ( + onToggle(ancestor.uri)} + authorName={ancestor.authorName} + replyCount={ancestor.replyCount} + opacity={opacity} + showChevron={false} + /> + ) + })} + + ) +} diff --git a/src/components/reply-branch.test.tsx b/src/components/reply-branch.test.tsx index 924f166..005d526 100644 --- a/src/components/reply-branch.test.tsx +++ b/src/components/reply-branch.test.tsx @@ -1,6 +1,5 @@ /** - * Tests for ReplyBranch collapse behavior. - * Covers auto-collapse by depth, sibling limiting, and thread line toggle. + * Tests for ReplyBranch collapse behavior and unified line system. */ import { describe, it, expect, vi } from 'vitest' @@ -10,7 +9,6 @@ import type { Reply } from '@/lib/api/types' import type { ReplyTreeNode } from '@/lib/build-reply-tree' import { ReplyBranch } from './reply-branch' -// Mock onboarding context (required by LikeButton via ReplyCard) vi.mock('@/context/onboarding-context', () => ({ useOnboardingContext: () => ({ state: { completed: true, dismissed: true, currentStep: null, completedSteps: [] }, @@ -103,8 +101,7 @@ function buildAllRepliesMap(nodes: ReplyTreeNode[]): Map { return map } -describe('ReplyBranch collapse behavior', () => { - // Build a deep tree: depth 1 -> 2 -> 3 -> 4 -> 5 +describe('ReplyBranch', () => { const depth1 = makeReply({ uri: 'at://test/r/d1', depth: 1, parentUri: TOPIC_URI }) const depth2 = makeReply({ uri: 'at://test/r/d2', depth: 2, parentUri: depth1.uri }) const depth3 = makeReply({ uri: 'at://test/r/d3', depth: 3, parentUri: depth2.uri }) @@ -120,83 +117,66 @@ describe('ReplyBranch collapse behavior', () => { const deepPostMap = buildPostNumberMap(deepTree) const deepAllReplies = buildAllRepliesMap(deepTree) + const defaultProps = { + postNumberMap: deepPostMap, + topicUri: TOPIC_URI, + allReplies: deepAllReplies, + indentStep: 22, + showChevron: true, + ancestors: [] as Array<{ + uri: string + authorName: string + replyCount: number + expanded: boolean + }>, + onToggleAncestor: vi.fn(), + } + it('renders first 3 levels expanded by default', () => { - render( - - ) - // Depth 1, 2, 3 should all be visible + render() expect(screen.getByText(depth1.content)).toBeInTheDocument() expect(screen.getByText(depth2.content)).toBeInTheDocument() expect(screen.getByText(depth3.content)).toBeInTheDocument() }) it('auto-collapses depth 4+ by default', () => { - render( - - ) - // Depth 4 and 5 should be hidden (depth 3 node's children are auto-collapsed) + render() expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() expect(screen.queryByText(depth5.content)).not.toBeInTheDocument() }) - it('shows "N replies hidden" for auto-collapsed threads', () => { - render( - - ) - // depth3 node has 2 descendants: depth4 and depth5 - expect(screen.getByText(/2 replies hidden/)).toBeInTheDocument() + it('shows reply count for collapsed threads', () => { + render() + expect(screen.getByText(/2 replies/)).toBeInTheDocument() }) it('toggles collapse when ThreadLine is clicked', async () => { const user = userEvent.setup() - render( - - ) + render() - // Depth 3 is visible, depth 4 is hidden (auto-collapsed) - expect(screen.getByText(depth3.content)).toBeInTheDocument() expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() - // Find the thread line button for depth 3 node (which has children) - // It should have aria-expanded="false" since its children are auto-collapsed const collapseButtons = screen.getAllByRole('button', { expanded: false }) - // Click the last one (depth 3's thread line) await user.click(collapseButtons[collapseButtons.length - 1]!) - // Now depth 4 should be visible expect(screen.getByText(depth4.content)).toBeInTheDocument() }) + it('does not render thread lines for leaf comments', () => { + const leaf = makeReply({ uri: 'at://test/r/leaf', depth: 1, parentUri: TOPIC_URI }) + const tree = [makeNode(leaf)] + const map = buildPostNumberMap(tree) + const allReplies = buildAllRepliesMap(tree) + + render( + + ) + + expect(screen.queryByRole('button', { name: /collapse/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /expand/i })).not.toBeInTheDocument() + }) + it('direct replies (depth 1) are never auto-collapsed by sibling limiting', () => { - // Create 7 root-level replies const roots: ReplyTreeNode[] = Array.from({ length: 7 }, (_, i) => makeNode( makeReply({ @@ -212,24 +192,15 @@ describe('ReplyBranch collapse behavior', () => { const allReplies = buildAllRepliesMap(roots) render( - + ) - // All 7 should be visible (no sibling limiting at depth 1) for (let i = 0; i < 7; i++) { expect(screen.getByText(`Root reply ${i}`)).toBeInTheDocument() } }) it('shows "Show N more replies" for 5+ siblings at depth 2+', () => { - // Create a parent with 6 children at depth 2 const parent = makeReply({ uri: 'at://test/r/parent', depth: 1, parentUri: TOPIC_URI }) const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => makeNode( @@ -247,65 +218,13 @@ describe('ReplyBranch collapse behavior', () => { const allReplies = buildAllRepliesMap(tree) render( - + ) - // First 3 children visible expect(screen.getByText('Child reply 0')).toBeInTheDocument() expect(screen.getByText('Child reply 1')).toBeInTheDocument() expect(screen.getByText('Child reply 2')).toBeInTheDocument() - - // Remaining 3 hidden with button expect(screen.queryByText('Child reply 3')).not.toBeInTheDocument() expect(screen.getByText('Show 3 more replies')).toBeInTheDocument() }) - - it('reveals hidden siblings when "Show more" is clicked', async () => { - const user = userEvent.setup() - - const parent = makeReply({ uri: 'at://test/r/parent2', depth: 1, parentUri: TOPIC_URI }) - const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => - makeNode( - makeReply({ - uri: `at://test/r/sib${i}`, - depth: 2, - parentUri: parent.uri, - content: `Sibling ${i}`, - }) - ) - ) - - const tree: ReplyTreeNode[] = [makeNode(parent, children)] - const map = buildPostNumberMap(tree) - const allReplies = buildAllRepliesMap(tree) - - render( - - ) - - // Click "Show 3 more replies" - await user.click(screen.getByText('Show 3 more replies')) - - // All 6 should now be visible - for (let i = 0; i < 6; i++) { - expect(screen.getByText(`Sibling ${i}`)).toBeInTheDocument() - } - - // Button should be gone - expect(screen.queryByText(/Show \d+ more/)).not.toBeInTheDocument() - }) }) diff --git a/src/components/reply-branch.tsx b/src/components/reply-branch.tsx index 18c2ae6..7f5185a 100644 --- a/src/components/reply-branch.tsx +++ b/src/components/reply-branch.tsx @@ -1,10 +1,8 @@ /** * ReplyBranch - Recursive tree renderer for threaded replies. - * Renders an
    of replies, each containing a ReplyCard and - * a nested for children. Thread lines appear next - * to replies with children for collapse/expand. Reply-to badges - * show when a reply's parent isn't visually adjacent. - * Respects visual indent cap — stops nesting beyond the cap. + * Uses a unified line system: one interactive ThreadLine per depth level. + * Ancestor lines continue through descendants for collapse-from-anywhere. + * Pixel-based indent steps replace Tailwind margin classes. * Auto-collapses depth 3+ threads and limits 5+ siblings at depth 2+. */ @@ -14,10 +12,13 @@ import { useState, useCallback } from 'react' import type { Reply } from '@/lib/api/types' import { type ReplyTreeNode, countDescendants } from '@/lib/build-reply-tree' import { + VISUAL_INDENT_CAP, DEFAULT_EXPANDED_LEVELS, AUTO_COLLAPSE_SIBLING_THRESHOLD, AUTO_COLLAPSE_SHOW_COUNT, } from '@/lib/threading-constants' +import type { AncestorInfo } from './ancestor-lines' +import { AncestorLines } from './ancestor-lines' import { ReplyCard } from './reply-card' import { ThreadLine } from './thread-line' import { ReplyToBadge } from './reply-to-badge' @@ -28,8 +29,12 @@ interface ReplyBranchProps { postNumberMap: Map topicUri: string allReplies: Map - visualIndentCap: number - currentVisualDepth: number + indentStep: number + showChevron: boolean + /** Ancestor line data passed through recursion. Outermost first. */ + ancestors?: AncestorInfo[] + /** Callback to collapse/expand an ancestor thread from anywhere. */ + onToggleAncestor?: (uri: string) => void /** URI of the parent node in the tree (topicUri for root level) */ treeParentUri?: string onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void @@ -42,14 +47,15 @@ export function ReplyBranch({ postNumberMap, topicUri, allReplies, - visualIndentCap, - currentVisualDepth, + indentStep, + showChevron, + ancestors = [], + onToggleAncestor, treeParentUri, onReply, onDeleteReply, currentUserDid, }: ReplyBranchProps) { - // Auto-collapse: nodes at depth >= DEFAULT_EXPANDED_LEVELS with children start collapsed const [collapsedNodes, setCollapsedNodes] = useState>(() => { const initial = new Set() for (const node of nodes) { @@ -60,7 +66,6 @@ export function ReplyBranch({ return initial }) - // Sibling limiting: 5+ siblings at depth 2+ show only first 3 const [showAllSiblings, setShowAllSiblings] = useState(false) const toggleCollapse = useCallback((uri: string) => { @@ -75,46 +80,73 @@ export function ReplyBranch({ }) }, []) + const handleToggle = useCallback( + (uri: string) => { + if (collapsedNodes.has(uri) || nodes.some((n) => n.reply.uri === uri)) { + toggleCollapse(uri) + } else { + onToggleAncestor?.(uri) + } + }, + [collapsedNodes, nodes, toggleCollapse, onToggleAncestor] + ) + if (nodes.length === 0) return null - // At root level, the expected parent is the topic itself const expectedParentUri = treeParentUri ?? topicUri - const atVisualCap = currentVisualDepth >= visualIndentCap - // Determine sibling limiting: depth 1 (direct replies) never limited const siblingDepth = nodes[0]?.reply.depth ?? 1 const shouldLimitSiblings = !showAllSiblings && siblingDepth >= 2 && nodes.length >= AUTO_COLLAPSE_SIBLING_THRESHOLD const visibleNodes = shouldLimitSiblings ? nodes.slice(0, AUTO_COLLAPSE_SHOW_COUNT) : nodes const hiddenSiblingCount = nodes.length - visibleNodes.length + const atVisualCap = (nodes[0]?.reply.depth ?? 1) >= VISUAL_INDENT_CAP + return ( -
      +
        {visibleNodes.map((node) => { const postNumber = postNumberMap.get(node.reply.uri) ?? 0 const hasChildren = node.children.length > 0 const isCollapsed = collapsedNodes.has(node.reply.uri) const authorName = node.reply.author?.displayName ?? node.reply.author?.handle ?? node.reply.authorDid + const descendantCount = hasChildren ? countDescendants(node) : 0 - // Show reply-to badge when the reply's actual parent differs from - // the structural parent in the tree (orphan or depth-capped) const needsBadge = node.reply.parentUri !== expectedParentUri const parentReply = needsBadge ? allReplies.get(node.reply.parentUri) : undefined const parentHandle = parentReply?.author?.handle ?? parentReply?.authorDid const parentPostNumber = parentReply ? (postNumberMap.get(parentReply.uri) ?? 0) : 0 + const childAncestors: AncestorInfo[] = hasChildren + ? [ + ...ancestors, + { + uri: node.reply.uri, + authorName, + replyCount: descendantCount, + expanded: !isCollapsed, + }, + ] + : ancestors + return (
      1. {needsBadge && parentHandle && parentPostNumber > 0 && ( - +
        + +
        )}
        + {hasChildren && ( toggleCollapse(node.reply.uri)} authorName={authorName} + replyCount={descendantCount} + opacity={1} + showChevron={showChevron} /> )}
        @@ -128,48 +160,52 @@ export function ReplyBranch({ />
        + {hasChildren && isCollapsed && ( + + )} {hasChildren && !isCollapsed && (atVisualCap ? ( - /* At the visual indent cap: render children flat at this level */ ) : ( - /* Below the cap: nest normally with indentation */ -
        +
        ))} - {hasChildren && - isCollapsed && - (() => { - const totalHidden = countDescendants(node) - return ( -

        - {totalHidden} {totalHidden === 1 ? 'reply' : 'replies'} hidden -

        - ) - })()}
      2. ) })} 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