Barazo default frontend barazo.forum

feat(web): redesign threaded comments with unified line system (#183)

* chore: add .claude/worktrees to gitignore

* feat(web): update threading constants for redesign

Replace viewport-specific indent caps with a universal cap of 10,
viewport-specific indent steps in pixels, and a line opacity gradient.

* feat(web): add useThreadIndent hook replacing useVisualIndentCap

Returns viewport-aware indent step (px) and chevron visibility.
Desktop: 22px + chevron, tablet: 16px + chevron, mobile: 8px no chevron.

* feat(web): redesign ThreadLine with chevron and opacity support

Add CaretDown/CaretRight chevron indicators (hidden on mobile via
showChevron prop). Add opacity prop for ancestor line fade effect.
Line and chevron share hover color transition.

* feat(web): add AncestorLines component for ancestor thread lines

Renders one ThreadLine per ancestor depth with opacity fading from
right (direct parent, full opacity) to left (distant ancestor, dim).
Each line independently collapses its ancestor's sub-thread.

* feat(web): rewrite ReplyBranch with unified line system

Remove border-l nesting divs. Pass ancestor line data through
recursion so each comment renders all ancestor lines to its left.
Use pixel-based indent steps. Cap visual depth at 10.

* feat(web): wire ReplyThread to new indent hook and ReplyBranch props

Replace useVisualIndentCap with useThreadIndent. Pass indentStep
and showChevron through to ReplyBranch. Remove old hook.

authored by

Guido X Jansen and committed by
GitHub
665cd3e7 682798aa

+378 -207
+1
.gitignore
··· 30 30 31 31 # worktrees 32 32 .worktrees 33 + .claude/worktrees 33 34 34 35 # debug 35 36 npm-debug.log*
+63
src/components/ancestor-lines.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import userEvent from '@testing-library/user-event' 4 + import { axe } from 'vitest-axe' 5 + import { AncestorLines } from './ancestor-lines' 6 + 7 + describe('AncestorLines', () => { 8 + it('renders one button per ancestor', () => { 9 + const ancestors = [ 10 + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, 11 + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, 12 + ] 13 + render(<AncestorLines ancestors={ancestors} onToggle={vi.fn()} showChevron={false} />) 14 + const buttons = screen.getAllByRole('button') 15 + expect(buttons).toHaveLength(2) 16 + }) 17 + 18 + it('applies decreasing opacity from right to left', () => { 19 + const ancestors = [ 20 + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, 21 + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, 22 + { uri: 'a3', authorName: 'Carol', replyCount: 1, expanded: true }, 23 + ] 24 + const { container } = render( 25 + <AncestorLines ancestors={ancestors} onToggle={vi.fn()} showChevron={false} /> 26 + ) 27 + const lines = container.querySelectorAll('[aria-hidden="true"]') 28 + expect(lines.length).toBeGreaterThan(0) 29 + // Verify different opacity values exist 30 + const opacities = Array.from(lines).map((l) => l.getAttribute('style')) 31 + const uniqueOpacities = new Set(opacities) 32 + expect(uniqueOpacities.size).toBeGreaterThan(1) 33 + }) 34 + 35 + it('calls onToggle with correct uri when an ancestor line is clicked', async () => { 36 + const user = userEvent.setup() 37 + const onToggle = vi.fn() 38 + const ancestors = [ 39 + { uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }, 40 + { uri: 'a2', authorName: 'Bob', replyCount: 5, expanded: true }, 41 + ] 42 + render(<AncestorLines ancestors={ancestors} onToggle={onToggle} showChevron={false} />) 43 + const buttons = screen.getAllByRole('button') 44 + await user.click(buttons[0]!) 45 + expect(onToggle).toHaveBeenCalledWith('a1') 46 + }) 47 + 48 + it('renders nothing when ancestors array is empty', () => { 49 + const { container } = render( 50 + <AncestorLines ancestors={[]} onToggle={vi.fn()} showChevron={false} /> 51 + ) 52 + expect(container.querySelector('button')).not.toBeInTheDocument() 53 + }) 54 + 55 + it('passes axe accessibility check', async () => { 56 + const ancestors = [{ uri: 'a1', authorName: 'Alice', replyCount: 3, expanded: true }] 57 + const { container } = render( 58 + <AncestorLines ancestors={ancestors} onToggle={vi.fn()} showChevron={false} /> 59 + ) 60 + const results = await axe(container) 61 + expect(results).toHaveNoViolations() 62 + }) 63 + })
+48
src/components/ancestor-lines.tsx
··· 1 + /** 2 + * AncestorLines - Renders vertical thread lines for all ancestor depths. 3 + * Each line is an independent collapse control for that ancestor's sub-thread. 4 + * Opacity fades from right (direct parent, full) to left (distant ancestor, dim). 5 + * Chevrons are never shown on ancestor lines (only on the direct parent's ThreadLine). 6 + */ 7 + 8 + import { LINE_OPACITY } from '@/lib/threading-constants' 9 + import { ThreadLine } from './thread-line' 10 + 11 + export interface AncestorInfo { 12 + uri: string 13 + authorName: string 14 + replyCount: number 15 + expanded: boolean 16 + } 17 + 18 + interface AncestorLinesProps { 19 + /** Ancestors ordered from outermost (index 0) to innermost (last index). */ 20 + ancestors: AncestorInfo[] 21 + onToggle: (uri: string) => void 22 + showChevron: boolean 23 + } 24 + 25 + export function AncestorLines({ ancestors, onToggle }: AncestorLinesProps) { 26 + if (ancestors.length === 0) return null 27 + 28 + return ( 29 + <> 30 + {ancestors.map((ancestor, index) => { 31 + const distanceFromRight = ancestors.length - 1 - index 32 + const opacity = LINE_OPACITY[distanceFromRight] ?? LINE_OPACITY[LINE_OPACITY.length - 1]! 33 + 34 + return ( 35 + <ThreadLine 36 + key={ancestor.uri} 37 + expanded={ancestor.expanded} 38 + onToggle={() => onToggle(ancestor.uri)} 39 + authorName={ancestor.authorName} 40 + replyCount={ancestor.replyCount} 41 + opacity={opacity} 42 + showChevron={false} 43 + /> 44 + ) 45 + })} 46 + </> 47 + ) 48 + }
+39 -120
src/components/reply-branch.test.tsx
··· 1 1 /** 2 - * Tests for ReplyBranch collapse behavior. 3 - * Covers auto-collapse by depth, sibling limiting, and thread line toggle. 2 + * Tests for ReplyBranch collapse behavior and unified line system. 4 3 */ 5 4 6 5 import { describe, it, expect, vi } from 'vitest' ··· 10 9 import type { ReplyTreeNode } from '@/lib/build-reply-tree' 11 10 import { ReplyBranch } from './reply-branch' 12 11 13 - // Mock onboarding context (required by LikeButton via ReplyCard) 14 12 vi.mock('@/context/onboarding-context', () => ({ 15 13 useOnboardingContext: () => ({ 16 14 state: { completed: true, dismissed: true, currentStep: null, completedSteps: [] }, ··· 103 101 return map 104 102 } 105 103 106 - describe('ReplyBranch collapse behavior', () => { 107 - // Build a deep tree: depth 1 -> 2 -> 3 -> 4 -> 5 104 + describe('ReplyBranch', () => { 108 105 const depth1 = makeReply({ uri: 'at://test/r/d1', depth: 1, parentUri: TOPIC_URI }) 109 106 const depth2 = makeReply({ uri: 'at://test/r/d2', depth: 2, parentUri: depth1.uri }) 110 107 const depth3 = makeReply({ uri: 'at://test/r/d3', depth: 3, parentUri: depth2.uri }) ··· 119 116 120 117 const deepPostMap = buildPostNumberMap(deepTree) 121 118 const deepAllReplies = buildAllRepliesMap(deepTree) 119 + 120 + const defaultProps = { 121 + postNumberMap: deepPostMap, 122 + topicUri: TOPIC_URI, 123 + allReplies: deepAllReplies, 124 + indentStep: 22, 125 + showChevron: true, 126 + ancestors: [] as Array<{ 127 + uri: string 128 + authorName: string 129 + replyCount: number 130 + expanded: boolean 131 + }>, 132 + onToggleAncestor: vi.fn(), 133 + } 122 134 123 135 it('renders first 3 levels expanded by default', () => { 124 - render( 125 - <ReplyBranch 126 - nodes={deepTree} 127 - postNumberMap={deepPostMap} 128 - topicUri={TOPIC_URI} 129 - allReplies={deepAllReplies} 130 - visualIndentCap={10} 131 - currentVisualDepth={1} 132 - /> 133 - ) 134 - // Depth 1, 2, 3 should all be visible 136 + render(<ReplyBranch nodes={deepTree} {...defaultProps} />) 135 137 expect(screen.getByText(depth1.content)).toBeInTheDocument() 136 138 expect(screen.getByText(depth2.content)).toBeInTheDocument() 137 139 expect(screen.getByText(depth3.content)).toBeInTheDocument() 138 140 }) 139 141 140 142 it('auto-collapses depth 4+ by default', () => { 141 - render( 142 - <ReplyBranch 143 - nodes={deepTree} 144 - postNumberMap={deepPostMap} 145 - topicUri={TOPIC_URI} 146 - allReplies={deepAllReplies} 147 - visualIndentCap={10} 148 - currentVisualDepth={1} 149 - /> 150 - ) 151 - // Depth 4 and 5 should be hidden (depth 3 node's children are auto-collapsed) 143 + render(<ReplyBranch nodes={deepTree} {...defaultProps} />) 152 144 expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() 153 145 expect(screen.queryByText(depth5.content)).not.toBeInTheDocument() 154 146 }) 155 147 156 - it('shows "N replies hidden" for auto-collapsed threads', () => { 157 - render( 158 - <ReplyBranch 159 - nodes={deepTree} 160 - postNumberMap={deepPostMap} 161 - topicUri={TOPIC_URI} 162 - allReplies={deepAllReplies} 163 - visualIndentCap={10} 164 - currentVisualDepth={1} 165 - /> 166 - ) 167 - // depth3 node has 2 descendants: depth4 and depth5 168 - expect(screen.getByText(/2 replies hidden/)).toBeInTheDocument() 148 + it('shows reply count for collapsed threads', () => { 149 + render(<ReplyBranch nodes={deepTree} {...defaultProps} />) 150 + expect(screen.getByText(/2 replies/)).toBeInTheDocument() 169 151 }) 170 152 171 153 it('toggles collapse when ThreadLine is clicked', async () => { 172 154 const user = userEvent.setup() 173 - render( 174 - <ReplyBranch 175 - nodes={deepTree} 176 - postNumberMap={deepPostMap} 177 - topicUri={TOPIC_URI} 178 - allReplies={deepAllReplies} 179 - visualIndentCap={10} 180 - currentVisualDepth={1} 181 - /> 182 - ) 155 + render(<ReplyBranch nodes={deepTree} {...defaultProps} />) 183 156 184 - // Depth 3 is visible, depth 4 is hidden (auto-collapsed) 185 - expect(screen.getByText(depth3.content)).toBeInTheDocument() 186 157 expect(screen.queryByText(depth4.content)).not.toBeInTheDocument() 187 158 188 - // Find the thread line button for depth 3 node (which has children) 189 - // It should have aria-expanded="false" since its children are auto-collapsed 190 159 const collapseButtons = screen.getAllByRole('button', { expanded: false }) 191 - // Click the last one (depth 3's thread line) 192 160 await user.click(collapseButtons[collapseButtons.length - 1]!) 193 161 194 - // Now depth 4 should be visible 195 162 expect(screen.getByText(depth4.content)).toBeInTheDocument() 196 163 }) 197 164 165 + it('does not render thread lines for leaf comments', () => { 166 + const leaf = makeReply({ uri: 'at://test/r/leaf', depth: 1, parentUri: TOPIC_URI }) 167 + const tree = [makeNode(leaf)] 168 + const map = buildPostNumberMap(tree) 169 + const allReplies = buildAllRepliesMap(tree) 170 + 171 + render( 172 + <ReplyBranch nodes={tree} {...defaultProps} postNumberMap={map} allReplies={allReplies} /> 173 + ) 174 + 175 + expect(screen.queryByRole('button', { name: /collapse/i })).not.toBeInTheDocument() 176 + expect(screen.queryByRole('button', { name: /expand/i })).not.toBeInTheDocument() 177 + }) 178 + 198 179 it('direct replies (depth 1) are never auto-collapsed by sibling limiting', () => { 199 - // Create 7 root-level replies 200 180 const roots: ReplyTreeNode[] = Array.from({ length: 7 }, (_, i) => 201 181 makeNode( 202 182 makeReply({ ··· 212 192 const allReplies = buildAllRepliesMap(roots) 213 193 214 194 render( 215 - <ReplyBranch 216 - nodes={roots} 217 - postNumberMap={map} 218 - topicUri={TOPIC_URI} 219 - allReplies={allReplies} 220 - visualIndentCap={10} 221 - currentVisualDepth={1} 222 - /> 195 + <ReplyBranch nodes={roots} {...defaultProps} postNumberMap={map} allReplies={allReplies} /> 223 196 ) 224 197 225 - // All 7 should be visible (no sibling limiting at depth 1) 226 198 for (let i = 0; i < 7; i++) { 227 199 expect(screen.getByText(`Root reply ${i}`)).toBeInTheDocument() 228 200 } 229 201 }) 230 202 231 203 it('shows "Show N more replies" for 5+ siblings at depth 2+', () => { 232 - // Create a parent with 6 children at depth 2 233 204 const parent = makeReply({ uri: 'at://test/r/parent', depth: 1, parentUri: TOPIC_URI }) 234 205 const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => 235 206 makeNode( ··· 247 218 const allReplies = buildAllRepliesMap(tree) 248 219 249 220 render( 250 - <ReplyBranch 251 - nodes={tree} 252 - postNumberMap={map} 253 - topicUri={TOPIC_URI} 254 - allReplies={allReplies} 255 - visualIndentCap={10} 256 - currentVisualDepth={1} 257 - /> 221 + <ReplyBranch nodes={tree} {...defaultProps} postNumberMap={map} allReplies={allReplies} /> 258 222 ) 259 223 260 - // First 3 children visible 261 224 expect(screen.getByText('Child reply 0')).toBeInTheDocument() 262 225 expect(screen.getByText('Child reply 1')).toBeInTheDocument() 263 226 expect(screen.getByText('Child reply 2')).toBeInTheDocument() 264 - 265 - // Remaining 3 hidden with button 266 227 expect(screen.queryByText('Child reply 3')).not.toBeInTheDocument() 267 228 expect(screen.getByText('Show 3 more replies')).toBeInTheDocument() 268 - }) 269 - 270 - it('reveals hidden siblings when "Show more" is clicked', async () => { 271 - const user = userEvent.setup() 272 - 273 - const parent = makeReply({ uri: 'at://test/r/parent2', depth: 1, parentUri: TOPIC_URI }) 274 - const children: ReplyTreeNode[] = Array.from({ length: 6 }, (_, i) => 275 - makeNode( 276 - makeReply({ 277 - uri: `at://test/r/sib${i}`, 278 - depth: 2, 279 - parentUri: parent.uri, 280 - content: `Sibling ${i}`, 281 - }) 282 - ) 283 - ) 284 - 285 - const tree: ReplyTreeNode[] = [makeNode(parent, children)] 286 - const map = buildPostNumberMap(tree) 287 - const allReplies = buildAllRepliesMap(tree) 288 - 289 - render( 290 - <ReplyBranch 291 - nodes={tree} 292 - postNumberMap={map} 293 - topicUri={TOPIC_URI} 294 - allReplies={allReplies} 295 - visualIndentCap={10} 296 - currentVisualDepth={1} 297 - /> 298 - ) 299 - 300 - // Click "Show 3 more replies" 301 - await user.click(screen.getByText('Show 3 more replies')) 302 - 303 - // All 6 should now be visible 304 - for (let i = 0; i < 6; i++) { 305 - expect(screen.getByText(`Sibling ${i}`)).toBeInTheDocument() 306 - } 307 - 308 - // Button should be gone 309 - expect(screen.queryByText(/Show \d+ more/)).not.toBeInTheDocument() 310 229 }) 311 230 })
+71 -35
src/components/reply-branch.tsx
··· 1 1 /** 2 2 * ReplyBranch - Recursive tree renderer for threaded replies. 3 - * Renders an <ol> of replies, each containing a ReplyCard and 4 - * a nested <ReplyBranch> for children. Thread lines appear next 5 - * to replies with children for collapse/expand. Reply-to badges 6 - * show when a reply's parent isn't visually adjacent. 7 - * Respects visual indent cap — stops nesting beyond the cap. 3 + * Uses a unified line system: one interactive ThreadLine per depth level. 4 + * Ancestor lines continue through descendants for collapse-from-anywhere. 5 + * Pixel-based indent steps replace Tailwind margin classes. 8 6 * Auto-collapses depth 3+ threads and limits 5+ siblings at depth 2+. 9 7 */ 10 8 ··· 14 12 import type { Reply } from '@/lib/api/types' 15 13 import { type ReplyTreeNode, countDescendants } from '@/lib/build-reply-tree' 16 14 import { 15 + VISUAL_INDENT_CAP, 17 16 DEFAULT_EXPANDED_LEVELS, 18 17 AUTO_COLLAPSE_SIBLING_THRESHOLD, 19 18 AUTO_COLLAPSE_SHOW_COUNT, 20 19 } from '@/lib/threading-constants' 20 + import type { AncestorInfo } from './ancestor-lines' 21 + import { AncestorLines } from './ancestor-lines' 21 22 import { ReplyCard } from './reply-card' 22 23 import { ThreadLine } from './thread-line' 23 24 import { ReplyToBadge } from './reply-to-badge' ··· 28 29 postNumberMap: Map<string, number> 29 30 topicUri: string 30 31 allReplies: Map<string, Reply> 31 - visualIndentCap: number 32 - currentVisualDepth: number 32 + indentStep: number 33 + showChevron: boolean 34 + /** Ancestor line data passed through recursion. Outermost first. */ 35 + ancestors?: AncestorInfo[] 36 + /** Callback to collapse/expand an ancestor thread from anywhere. */ 37 + onToggleAncestor?: (uri: string) => void 33 38 /** URI of the parent node in the tree (topicUri for root level) */ 34 39 treeParentUri?: string 35 40 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void ··· 42 47 postNumberMap, 43 48 topicUri, 44 49 allReplies, 45 - visualIndentCap, 46 - currentVisualDepth, 50 + indentStep, 51 + showChevron, 52 + ancestors = [], 53 + onToggleAncestor, 47 54 treeParentUri, 48 55 onReply, 49 56 onDeleteReply, 50 57 currentUserDid, 51 58 }: ReplyBranchProps) { 52 - // Auto-collapse: nodes at depth >= DEFAULT_EXPANDED_LEVELS with children start collapsed 53 59 const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(() => { 54 60 const initial = new Set<string>() 55 61 for (const node of nodes) { ··· 60 66 return initial 61 67 }) 62 68 63 - // Sibling limiting: 5+ siblings at depth 2+ show only first 3 64 69 const [showAllSiblings, setShowAllSiblings] = useState(false) 65 70 66 71 const toggleCollapse = useCallback((uri: string) => { ··· 75 80 }) 76 81 }, []) 77 82 83 + const handleToggle = useCallback( 84 + (uri: string) => { 85 + if (collapsedNodes.has(uri) || nodes.some((n) => n.reply.uri === uri)) { 86 + toggleCollapse(uri) 87 + } else { 88 + onToggleAncestor?.(uri) 89 + } 90 + }, 91 + [collapsedNodes, nodes, toggleCollapse, onToggleAncestor] 92 + ) 93 + 78 94 if (nodes.length === 0) return null 79 95 80 - // At root level, the expected parent is the topic itself 81 96 const expectedParentUri = treeParentUri ?? topicUri 82 - const atVisualCap = currentVisualDepth >= visualIndentCap 83 97 84 - // Determine sibling limiting: depth 1 (direct replies) never limited 85 98 const siblingDepth = nodes[0]?.reply.depth ?? 1 86 99 const shouldLimitSiblings = 87 100 !showAllSiblings && siblingDepth >= 2 && nodes.length >= AUTO_COLLAPSE_SIBLING_THRESHOLD 88 101 const visibleNodes = shouldLimitSiblings ? nodes.slice(0, AUTO_COLLAPSE_SHOW_COUNT) : nodes 89 102 const hiddenSiblingCount = nodes.length - visibleNodes.length 90 103 104 + const atVisualCap = (nodes[0]?.reply.depth ?? 1) >= VISUAL_INDENT_CAP 105 + 91 106 return ( 92 - <ol className="list-none space-y-3 pl-0 first:pl-0 [&_&]:mt-3 [&_&]:pl-0"> 107 + <ol className="list-none space-y-3 pl-0"> 93 108 {visibleNodes.map((node) => { 94 109 const postNumber = postNumberMap.get(node.reply.uri) ?? 0 95 110 const hasChildren = node.children.length > 0 96 111 const isCollapsed = collapsedNodes.has(node.reply.uri) 97 112 const authorName = 98 113 node.reply.author?.displayName ?? node.reply.author?.handle ?? node.reply.authorDid 114 + const descendantCount = hasChildren ? countDescendants(node) : 0 99 115 100 - // Show reply-to badge when the reply's actual parent differs from 101 - // the structural parent in the tree (orphan or depth-capped) 102 116 const needsBadge = node.reply.parentUri !== expectedParentUri 103 117 const parentReply = needsBadge ? allReplies.get(node.reply.parentUri) : undefined 104 118 const parentHandle = parentReply?.author?.handle ?? parentReply?.authorDid 105 119 const parentPostNumber = parentReply ? (postNumberMap.get(parentReply.uri) ?? 0) : 0 106 120 121 + const childAncestors: AncestorInfo[] = hasChildren 122 + ? [ 123 + ...ancestors, 124 + { 125 + uri: node.reply.uri, 126 + authorName, 127 + replyCount: descendantCount, 128 + expanded: !isCollapsed, 129 + }, 130 + ] 131 + : ancestors 132 + 107 133 return ( 108 134 <li key={node.reply.uri} aria-level={node.reply.depth}> 109 135 {needsBadge && parentHandle && parentPostNumber > 0 && ( 110 - <ReplyToBadge authorHandle={parentHandle} parentPostNumber={parentPostNumber} /> 136 + <div style={{ marginLeft: ancestors.length * 44 }}> 137 + <ReplyToBadge authorHandle={parentHandle} parentPostNumber={parentPostNumber} /> 138 + </div> 111 139 )} 112 140 <div className="flex gap-0"> 141 + <AncestorLines ancestors={ancestors} onToggle={handleToggle} showChevron={false} /> 113 142 {hasChildren && ( 114 143 <ThreadLine 115 144 expanded={!isCollapsed} 116 145 onToggle={() => toggleCollapse(node.reply.uri)} 117 146 authorName={authorName} 147 + replyCount={descendantCount} 148 + opacity={1} 149 + showChevron={showChevron} 118 150 /> 119 151 )} 120 152 <div className="min-w-0 flex-1"> ··· 128 160 /> 129 161 </div> 130 162 </div> 163 + {hasChildren && isCollapsed && ( 164 + <button 165 + type="button" 166 + onClick={() => toggleCollapse(node.reply.uri)} 167 + className="mt-1 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground" 168 + style={{ marginLeft: (ancestors.length + 1) * 44 }} 169 + aria-live="polite" 170 + > 171 + {descendantCount} {descendantCount === 1 ? 'reply' : 'replies'} 172 + </button> 173 + )} 131 174 {hasChildren && 132 175 !isCollapsed && 133 176 (atVisualCap ? ( 134 - /* At the visual indent cap: render children flat at this level */ 135 177 <ReplyBranch 136 178 nodes={node.children} 137 179 postNumberMap={postNumberMap} 138 180 topicUri={topicUri} 139 181 allReplies={allReplies} 140 - visualIndentCap={visualIndentCap} 141 - currentVisualDepth={currentVisualDepth} 182 + indentStep={indentStep} 183 + showChevron={showChevron} 184 + ancestors={childAncestors} 185 + onToggleAncestor={handleToggle} 142 186 treeParentUri={node.reply.uri} 143 187 onReply={onReply} 144 188 onDeleteReply={onDeleteReply} 145 189 currentUserDid={currentUserDid} 146 190 /> 147 191 ) : ( 148 - /* Below the cap: nest normally with indentation */ 149 - <div className="ml-5 border-l border-border pl-3 sm:ml-[22px] sm:pl-4"> 192 + <div style={{ marginLeft: indentStep }}> 150 193 <ReplyBranch 151 194 nodes={node.children} 152 195 postNumberMap={postNumberMap} 153 196 topicUri={topicUri} 154 197 allReplies={allReplies} 155 - visualIndentCap={visualIndentCap} 156 - currentVisualDepth={currentVisualDepth + 1} 198 + indentStep={indentStep} 199 + showChevron={showChevron} 200 + ancestors={childAncestors} 201 + onToggleAncestor={handleToggle} 157 202 treeParentUri={node.reply.uri} 158 203 onReply={onReply} 204 + onDeleteReply={onDeleteReply} 159 205 currentUserDid={currentUserDid} 160 206 /> 161 207 </div> 162 208 ))} 163 - {hasChildren && 164 - isCollapsed && 165 - (() => { 166 - const totalHidden = countDescendants(node) 167 - return ( 168 - <p className="ml-12 mt-1 text-xs text-muted-foreground" aria-live="polite"> 169 - {totalHidden} {totalHidden === 1 ? 'reply' : 'replies'} hidden 170 - </p> 171 - ) 172 - })()} 173 209 </li> 174 210 ) 175 211 })}
+5 -5
src/components/reply-thread.tsx
··· 2 2 * ReplyThread - Displays a threaded tree of replies. 3 3 * Reconstructs tree from flat API response, assigns depth-first post numbers. 4 4 * Post numbers start at 2 (post #1 is the topic itself). 5 - * Responsive visual indent caps limit nesting on smaller screens. 5 + * Uses viewport-aware indent steps for responsive nesting. 6 6 * @see specs/prd-web.md Section 4 (Topic Components) 7 7 */ 8 8 ··· 12 12 import type { Reply } from '@/lib/api/types' 13 13 import { cn } from '@/lib/utils' 14 14 import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree' 15 - import { useVisualIndentCap } from '@/hooks/use-visual-indent-cap' 15 + import { useThreadIndent } from '@/hooks/use-thread-indent' 16 16 import { ReplyBranch } from './reply-branch' 17 17 18 18 interface ReplyThreadProps { ··· 36 36 const heading = 37 37 replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` 38 38 39 - const visualIndentCap = useVisualIndentCap() 39 + const { indentStep, showChevron } = useThreadIndent() 40 40 41 41 const { tree, postNumberMap, allReplies } = useMemo(() => { 42 42 const builtTree = buildReplyTree(replies, topicUri) ··· 63 63 postNumberMap={postNumberMap} 64 64 topicUri={topicUri} 65 65 allReplies={allReplies} 66 - visualIndentCap={visualIndentCap} 67 - currentVisualDepth={1} 66 + indentStep={indentStep} 67 + showChevron={showChevron} 68 68 onReply={onReply} 69 69 onDeleteReply={onDeleteReply} 70 70 currentUserDid={currentUserDid}
+39 -21
src/components/thread-line.test.tsx
··· 1 - /** 2 - * Tests for ThreadLine component. 3 - */ 4 - 5 1 import { describe, it, expect, vi } from 'vitest' 6 2 import { render, screen } from '@testing-library/react' 7 3 import userEvent from '@testing-library/user-event' ··· 9 5 import { ThreadLine } from './thread-line' 10 6 11 7 describe('ThreadLine', () => { 8 + const defaultProps = { 9 + expanded: true, 10 + onToggle: vi.fn(), 11 + authorName: 'Alex', 12 + replyCount: 5, 13 + opacity: 1, 14 + showChevron: true, 15 + } 16 + 12 17 it('renders as a button', () => { 13 - render(<ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" />) 18 + render(<ThreadLine {...defaultProps} />) 14 19 expect(screen.getByRole('button')).toBeInTheDocument() 15 20 }) 16 21 17 22 it('has aria-expanded matching expanded prop', () => { 18 - const { rerender } = render(<ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" />) 23 + const { rerender } = render(<ThreadLine {...defaultProps} expanded={true} />) 19 24 expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') 20 25 21 - rerender(<ThreadLine expanded={false} onToggle={vi.fn()} authorName="Alex" />) 26 + rerender(<ThreadLine {...defaultProps} expanded={false} />) 22 27 expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false') 23 28 }) 24 29 25 - it('has descriptive aria-label', () => { 26 - render(<ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" />) 27 - expect(screen.getByRole('button')).toHaveAttribute( 28 - 'aria-label', 29 - expect.stringContaining('Alex') 30 - ) 30 + it('has descriptive aria-label including author and reply count', () => { 31 + render(<ThreadLine {...defaultProps} expanded={true} replyCount={5} />) 32 + const label = screen.getByRole('button').getAttribute('aria-label')! 33 + expect(label).toContain('Alex') 34 + expect(label).toContain('5') 31 35 }) 32 36 33 37 it('calls onToggle when clicked', async () => { 34 38 const user = userEvent.setup() 35 39 const onToggle = vi.fn() 36 - render(<ThreadLine expanded={true} onToggle={onToggle} authorName="Alex" />) 40 + render(<ThreadLine {...defaultProps} onToggle={onToggle} />) 37 41 await user.click(screen.getByRole('button')) 38 42 expect(onToggle).toHaveBeenCalledTimes(1) 39 43 }) 40 44 41 - it('has adequate tap target (min 44px width)', () => { 45 + it('renders chevron icon when showChevron is true', () => { 42 46 const { container } = render( 43 - <ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" /> 47 + <ThreadLine {...defaultProps} expanded={true} showChevron={true} /> 44 48 ) 49 + expect(container.querySelector('svg')).toBeInTheDocument() 50 + }) 51 + 52 + it('does not render chevron icon when showChevron is false', () => { 53 + const { container } = render(<ThreadLine {...defaultProps} showChevron={false} />) 54 + expect(container.querySelector('svg')).not.toBeInTheDocument() 55 + }) 56 + 57 + it('applies opacity style to the line', () => { 58 + const { container } = render(<ThreadLine {...defaultProps} opacity={0.5} />) 59 + const line = container.querySelector('[aria-hidden="true"]') 60 + expect(line).toBeInTheDocument() 61 + expect(line!.getAttribute('style')).toContain('opacity') 62 + }) 63 + 64 + it('has adequate tap target (min 44px)', () => { 65 + const { container } = render(<ThreadLine {...defaultProps} />) 45 66 const button = container.querySelector('button')! 46 - // The button should have min-width of 44px via class 47 - expect(button.className).toMatch(/min-w-\[44px\]|w-11/) 67 + expect(button.className).toMatch(/min-w-\[44px\]/) 48 68 }) 49 69 50 70 it('passes axe accessibility check', async () => { 51 - const { container } = render( 52 - <ThreadLine expanded={true} onToggle={vi.fn()} authorName="Alex" /> 53 - ) 71 + const { container } = render(<ThreadLine {...defaultProps} />) 54 72 const results = await axe(container) 55 73 expect(results).toHaveNoViolations() 56 74 })
+36 -8
src/components/thread-line.tsx
··· 1 1 /** 2 - * ThreadLine - Clickable vertical line for collapsing thread branches. 2 + * ThreadLine - Clickable vertical line with chevron for collapsing thread branches. 3 3 * Visual width: 2px. Tap target: 44px minimum for accessibility. 4 + * Chevron: CaretDown (expanded) / CaretRight (collapsed). Hidden on mobile. 5 + * Opacity: controlled by parent to create depth-fade effect for ancestor lines. 4 6 */ 7 + 8 + import { CaretDown, CaretRight } from '@phosphor-icons/react' 5 9 6 10 interface ThreadLineProps { 7 11 expanded: boolean 8 12 onToggle: () => void 9 13 authorName: string 14 + replyCount: number 15 + opacity?: number 16 + showChevron?: boolean 10 17 } 11 18 12 - export function ThreadLine({ expanded, onToggle, authorName }: ThreadLineProps) { 13 - const label = expanded ? `Collapse thread by ${authorName}` : `Expand thread by ${authorName}` 19 + export function ThreadLine({ 20 + expanded, 21 + onToggle, 22 + authorName, 23 + replyCount, 24 + opacity = 1, 25 + showChevron = true, 26 + }: ThreadLineProps) { 27 + const label = expanded 28 + ? `Collapse thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` 29 + : `Expand thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` 14 30 15 31 return ( 16 32 <button ··· 18 34 onClick={onToggle} 19 35 aria-expanded={expanded} 20 36 aria-label={label} 21 - title={expanded ? 'Collapse this thread' : 'Expand this thread'} 37 + title={expanded ? 'Collapse thread' : 'Expand thread'} 22 38 className="group relative min-w-[44px] shrink-0 cursor-pointer border-none bg-transparent p-0" 23 39 > 24 - <span 25 - aria-hidden="true" 26 - className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-border transition-colors group-hover:bg-accent-foreground" 27 - /> 40 + {showChevron && ( 41 + <span className="absolute left-1/2 top-1 -translate-x-1/2 text-border transition-colors group-hover:text-accent-foreground"> 42 + {expanded ? ( 43 + <CaretDown className="h-3 w-3" weight="bold" /> 44 + ) : ( 45 + <CaretRight className="h-3 w-3" weight="bold" /> 46 + )} 47 + </span> 48 + )} 49 + {expanded && ( 50 + <span 51 + aria-hidden="true" 52 + className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-border transition-colors group-hover:bg-accent-foreground" 53 + style={{ opacity, top: showChevron ? '1.25rem' : 0 }} 54 + /> 55 + )} 28 56 </button> 29 57 ) 30 58 }
+45
src/hooks/use-thread-indent.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest' 2 + import { renderHook } from '@testing-library/react' 3 + import { useThreadIndent } from './use-thread-indent' 4 + 5 + vi.mock('./use-media-query', () => ({ 6 + useMediaQuery: vi.fn(), 7 + })) 8 + 9 + import { useMediaQuery } from './use-media-query' 10 + const mockUseMediaQuery = vi.mocked(useMediaQuery) 11 + 12 + describe('useThreadIndent', () => { 13 + beforeEach(() => { 14 + mockUseMediaQuery.mockReset() 15 + }) 16 + 17 + it('returns desktop indent step for wide viewports', () => { 18 + mockUseMediaQuery.mockImplementation((q: string) => { 19 + if (q === '(min-width: 768px)') return true 20 + if (q === '(min-width: 481px)') return true 21 + return false 22 + }) 23 + const { result } = renderHook(() => useThreadIndent()) 24 + expect(result.current.indentStep).toBe(22) 25 + expect(result.current.showChevron).toBe(true) 26 + }) 27 + 28 + it('returns tablet indent step for medium viewports', () => { 29 + mockUseMediaQuery.mockImplementation((q: string) => { 30 + if (q === '(min-width: 768px)') return false 31 + if (q === '(min-width: 481px)') return true 32 + return false 33 + }) 34 + const { result } = renderHook(() => useThreadIndent()) 35 + expect(result.current.indentStep).toBe(16) 36 + expect(result.current.showChevron).toBe(true) 37 + }) 38 + 39 + it('returns mobile indent step and hides chevron for narrow viewports', () => { 40 + mockUseMediaQuery.mockImplementation(() => false) 41 + const { result } = renderHook(() => useThreadIndent()) 42 + expect(result.current.indentStep).toBe(8) 43 + expect(result.current.showChevron).toBe(false) 44 + }) 45 + })
+22
src/hooks/use-thread-indent.ts
··· 1 + /** 2 + * Returns thread indent configuration based on viewport width. 3 + * - indentStep: pixels per indent level 4 + * - showChevron: whether to render the chevron icon (hidden on mobile) 5 + */ 6 + 7 + import { INDENT_STEP } from '@/lib/threading-constants' 8 + import { useMediaQuery } from './use-media-query' 9 + 10 + interface ThreadIndent { 11 + indentStep: number 12 + showChevron: boolean 13 + } 14 + 15 + export function useThreadIndent(): ThreadIndent { 16 + const isDesktop = useMediaQuery('(min-width: 768px)') 17 + const isTablet = useMediaQuery('(min-width: 481px)') 18 + 19 + if (isDesktop) return { indentStep: INDENT_STEP.desktop, showChevron: true } 20 + if (isTablet) return { indentStep: INDENT_STEP.tablet, showChevron: true } 21 + return { indentStep: INDENT_STEP.mobile, showChevron: false } 22 + }
-17
src/hooks/use-visual-indent-cap.ts
··· 1 - /** 2 - * Returns the maximum visual indent level based on viewport width. 3 - * Desktop (>=768px): 4, Tablet (>=481px): 3, Mobile (<481px): 2. 4 - * Defaults to desktop value during SSR. 5 - */ 6 - 7 - import { VISUAL_INDENT_CAPS } from '@/lib/threading-constants' 8 - import { useMediaQuery } from './use-media-query' 9 - 10 - export function useVisualIndentCap(): number { 11 - const isDesktop = useMediaQuery('(min-width: 768px)') 12 - const isTablet = useMediaQuery('(min-width: 481px)') 13 - 14 - if (isDesktop) return VISUAL_INDENT_CAPS.desktop 15 - if (isTablet) return VISUAL_INDENT_CAPS.tablet 16 - return VISUAL_INDENT_CAPS.mobile 17 - }
+9 -1
src/lib/threading-constants.ts
··· 1 1 export const MAX_REPLY_DEPTH_DEFAULT = 9999 2 2 3 - export const VISUAL_INDENT_CAPS = { desktop: 4, tablet: 3, mobile: 2 } as const 3 + export const VISUAL_INDENT_CAP = 10 4 + 5 + export const INDENT_STEP = { desktop: 22, tablet: 16, mobile: 8 } as const 4 6 5 7 export const DEFAULT_EXPANDED_LEVELS = 3 6 8 7 9 export const AUTO_COLLAPSE_SIBLING_THRESHOLD = 5 8 10 9 11 export const AUTO_COLLAPSE_SHOW_COUNT = 3 12 + 13 + /** 14 + * Opacity values for ancestor thread lines. 15 + * Index 0 = direct parent (rightmost line), higher indices = further ancestors. 16 + */ 17 + 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