diff --git a/src/app/globals.css b/src/app/globals.css
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -270,3 +270,16 @@
background-color: var(--color-primary-muted);
color: var(--color-primary);
}
+
+/* Component classes */
+@layer components {
+ .prose-barazo a {
+ color: var(--cyan-11);
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, var(--cyan-11) 40%, transparent);
+ text-underline-offset: 2px;
+ }
+ .prose-barazo a:hover {
+ text-decoration-color: var(--cyan-11);
+ }
+}
diff --git a/src/lib/format-bio.test.ts b/src/lib/format-bio.test.ts
--- a/src/lib/format-bio.test.ts
+++ b/src/lib/format-bio.test.ts
@@ -41,4 +41,26 @@
const result = formatBio('Not a link: example.com')
expect(result).not.toContain(' {
+ const result = formatBio('Visit https://example.com/path/')
+ expect(result).toContain('example.com/path')
+ })
+
+ it('strips trailing slash from display text', () => {
+ const result = formatBio('Visit https://example.com/')
+ expect(result).toContain('>example.com')
+ })
+
+ it('keeps full URL in href', () => {
+ const result = formatBio('Visit https://example.com/path/')
+ expect(result).toContain('href="https://example.com/path/"')
+ })
+
+ it('strips http:// from display text', () => {
+ const result = formatBio('Visit http://example.com/page')
+ expect(result).toContain('example.com/page')
+ })
})
diff --git a/src/lib/format-bio.ts b/src/lib/format-bio.ts
--- a/src/lib/format-bio.ts
+++ b/src/lib/format-bio.ts
@@ -16,10 +16,11 @@
.replace(/'/g, ''')
// Step 2: Autolink URLs (only http:// and https://)
- result = result.replace(
- /https?:\/\/[^\s<]+/g,
- (url) => `${url}`
- )
+ // Display text strips protocol prefix and trailing slash for cleaner appearance.
+ result = result.replace(/https?:\/\/[^\s<]+/g, (url) => {
+ const display = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
+ return `${display}`
+ })
// Step 3: Convert newlines to
result = result.replace(/\n/g, '
')
diff --git a/src/lib/format-count.test.ts b/src/lib/format-count.test.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/format-count.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { formatCount } from './format-count'
+
+describe('formatCount', () => {
+ it('returns "0" for zero', () => {
+ expect(formatCount(0)).toBe('0')
+ })
+
+ it('returns number as-is below 1000', () => {
+ expect(formatCount(999)).toBe('999')
+ })
+
+ it('formats exact thousands as whole K', () => {
+ expect(formatCount(1000)).toBe('1K')
+ })
+
+ it('formats non-exact thousands with one decimal', () => {
+ expect(formatCount(1500)).toBe('1.5K')
+ })
+
+ it('formats 1700 as 1.7K', () => {
+ expect(formatCount(1700)).toBe('1.7K')
+ })
+
+ it('formats 14100 as 14.1K', () => {
+ expect(formatCount(14100)).toBe('14.1K')
+ })
+
+ it('formats exact millions as whole M', () => {
+ expect(formatCount(1000000)).toBe('1M')
+ })
+
+ it('formats non-exact millions with one decimal', () => {
+ expect(formatCount(2300000)).toBe('2.3M')
+ })
+})
diff --git a/src/lib/format-count.ts b/src/lib/format-count.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/format-count.ts
@@ -0,0 +1,13 @@
+/**
+ * Abbreviates large numbers for display (e.g., 1500 -> "1.5K").
+ * Full number should be shown via title attribute for accessibility.
+ */
+export function formatCount(n: number): string {
+ if (n < 1000) return n.toString()
+ if (n < 1_000_000) {
+ const k = n / 1000
+ return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`
+ }
+ const m = n / 1_000_000
+ return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`
+}
diff --git a/src/components/profile/profile-header.test.tsx b/src/components/profile/profile-header.test.tsx
--- a/src/components/profile/profile-header.test.tsx
+++ b/src/components/profile/profile-header.test.tsx
@@ -87,7 +87,7 @@
expect(screen.getByRole('heading', { name: /test user/i })).toBeInTheDocument()
})
- it('renders handle', () => {
+ it('renders handle inline with display name', () => {
render()
expect(screen.getByText(/@test\.bsky\.social/)).toBeInTheDocument()
})
@@ -96,19 +96,18 @@
const { container } = render(
)
- // The bio div uses dangerouslySetInnerHTML, select by div.mt-2.text-muted-foreground
- const bioDiv = container.querySelector('div.mt-2.text-sm.text-muted-foreground')
+ const bioDiv = container.querySelector('div.prose-barazo')
expect(bioDiv?.innerHTML).toContain('
')
})
- it('renders bio with autolinked URLs', () => {
+ it('renders bio with autolinked URLs (stripped display text)', () => {
render(
)
- const link = screen.getByRole('link', { name: /https:\/\/example\.com/i })
+ const link = screen.getByRole('link', { name: /example\.com/i })
expect(link).toHaveAttribute('href', 'https://example.com')
})
@@ -133,44 +132,23 @@
expect(screen.queryByAltText("Test User's avatar")).not.toBeInTheDocument()
})
- it('renders followers and following counts', () => {
- render(
-
- )
- expect(screen.getByText(/150 followers/i)).toBeInTheDocument()
- expect(screen.getByText(/75 following/i)).toBeInTheDocument()
+ it('renders an hr separator', () => {
+ const { container } = render()
+ expect(container.querySelector('hr')).toBeInTheDocument()
})
- it('renders Bluesky link when hasBlueskyProfile is true', () => {
- render()
- const link = screen.getByRole('link', { name: /view on bluesky/i })
- expect(link).toHaveAttribute('href', 'https://bsky.app/profile/test.bsky.social')
+ // Section label tests
+ it('renders "This forum" section label', () => {
+ render()
+ expect(screen.getByText('This forum')).toBeInTheDocument()
})
- it('does not render Bluesky link when hasBlueskyProfile is false', () => {
- render(
-
- )
- expect(screen.queryByRole('link', { name: /view on bluesky/i })).not.toBeInTheDocument()
+ it('renders "AT Protocol" section label', () => {
+ render()
+ expect(screen.getByText('AT Protocol')).toBeInTheDocument()
})
- it('renders votesReceived in stats', () => {
- render(
-
- )
- expect(screen.getByText(/15 votes/i)).toBeInTheDocument()
- })
-
- it('renders globalActivity section when present', () => {
+ it('renders "Barazo-wide" section label when globalActivity present', () => {
render(
)
- expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument()
- expect(screen.getByText(/25 topics/i)).toBeInTheDocument()
+ expect(screen.getByText('Barazo-wide')).toBeInTheDocument()
})
- it('does not render globalActivity section when absent', () => {
+ it('does not render "Barazo-wide" section label when globalActivity absent', () => {
render()
- expect(screen.queryByText(/activity across all communities/i)).not.toBeInTheDocument()
+ expect(screen.queryByText('Barazo-wide')).not.toBeInTheDocument()
+ })
+
+ // Stats value tests
+ it('renders followers and following counts', () => {
+ render(
+
+ )
+ expect(screen.getByText(/150 followers/i)).toBeInTheDocument()
+ expect(screen.getByText(/75 following/i)).toBeInTheDocument()
+ })
+
+ it('renders Bluesky link with stripped URL display when hasBlueskyProfile is true', () => {
+ render()
+ const link = screen.getByRole('link', { name: /bsky\.app\/profile\/test\.bsky\.social/i })
+ expect(link).toHaveAttribute('href', 'https://bsky.app/profile/test.bsky.social')
+ })
+
+ it('does not render Bluesky link when hasBlueskyProfile is false', () => {
+ render(
+
+ )
+ expect(screen.queryByRole('link', { name: /bsky\.app\/profile/i })).not.toBeInTheDocument()
+ })
+
+ it('renders votesReceived in "This forum" stats', () => {
+ render(
+
+ )
+ expect(screen.getByText(/15 votes/i)).toBeInTheDocument()
+ })
+
+ it('renders globalActivity stats when present', () => {
+ render(
+
+ )
+ expect(screen.getByText(/25 topics/i)).toBeInTheDocument()
+ expect(screen.getByText(/60 replies/i)).toBeInTheDocument()
+ })
+
+ it('shows full number in title attribute for large counts', () => {
+ render(
+
+ )
+ expect(screen.getByTitle('14,100')).toBeInTheDocument()
+ expect(screen.getByTitle('1,500')).toBeInTheDocument()
+ })
+
+ it('abbreviates large numbers with formatCount', () => {
+ render(
+
+ )
+ expect(screen.getByText(/14\.1K followers/i)).toBeInTheDocument()
+ expect(screen.getByText(/1\.5K posts/i)).toBeInTheDocument()
})
describe('edit profile button', () => {
diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx
--- a/src/components/profile/profile-header.tsx
+++ b/src/components/profile/profile-header.tsx
@@ -8,11 +8,22 @@
import Link from 'next/link'
import Image from 'next/image'
-import { User, CalendarBlank, ChatCircle, ArrowUp, PencilSimple } from '@phosphor-icons/react'
+import {
+ User,
+ CalendarBlank,
+ ChatCircle,
+ ArrowUp,
+ PencilSimple,
+ HouseSimple,
+ At,
+ Globe,
+ Heart,
+} from '@phosphor-icons/react'
import { ReputationBadge } from '@/components/reputation-badge'
import { BlockMuteButton } from '@/components/block-mute-button'
import { ProfileStats } from '@/components/profile/profile-stats'
import { formatBio } from '@/lib/format-bio'
+import { formatCount } from '@/lib/format-count'
import type { UserProfile } from '@/lib/api/types'
interface ProfileHeaderProps {
@@ -70,10 +81,14 @@
)}
-
+ {/* Display name + handle inline + edit button */}
+
{profile.displayName ?? handle}
+ {profile.displayName && (
+ @{handle}
+ )}
{isOwnProfile && (
)}
- {profile.displayName &&
@{handle}
}
{/* Bio */}
{profile.bio && (
)}
-
-
-
-
- {postCount} {postCount === 1 ? 'post' : 'posts'}
-
-
-
- {profile.activity.votesReceived}{' '}
- {profile.activity.votesReceived === 1 ? 'vote' : 'votes'}
-
-
-
- Joined {joinDate}
-
-
+
-
+ {/* Labeled stats sections */}
+
+ {/* This forum */}
+
+
+
+ This forum
+
+
+
+
+
+ {formatCount(postCount)} {postCount === 1 ? 'post' : 'posts'}
+
+
+
+
+
+ {formatCount(profile.activity.reactionsReceived)}{' '}
+ {profile.activity.reactionsReceived === 1 ? 'reaction' : 'reactions'}
+
+
+
+
+
+ {formatCount(profile.activity.votesReceived)}{' '}
+ {profile.activity.votesReceived === 1 ? 'vote' : 'votes'}
+
+
+
+
+
+ Joined {joinDate}
+
+
+
+
+ {/* AT Protocol */}
+
+
+ {/* Barazo-wide (conditional) */}
+ {profile.globalActivity && (
+
+
+
+ Barazo-wide
+
+
+
+ {formatCount(profile.globalActivity.topicCount)} topics
+
+
+ {formatCount(profile.globalActivity.replyCount)} replies
+
+
+ {formatCount(profile.globalActivity.reactionsReceived)} reactions
+
+
+ {formatCount(profile.globalActivity.votesReceived)} votes
+
+
+
+ )}
+
{/* Block/Mute actions (hidden on own profile) */}
{!isOwnProfile && (
@@ -128,20 +196,6 @@
isActive={isMuted}
onToggle={onMuteToggle}
/>
-
- )}
-
- {profile.globalActivity && (
-
-
- Activity across all communities
-
-
- {profile.globalActivity.topicCount} topics
- {profile.globalActivity.replyCount} replies
- {profile.globalActivity.reactionsReceived} reactions
- {profile.globalActivity.votesReceived} votes
-
)}
diff --git a/src/components/profile/profile-stats.tsx b/src/components/profile/profile-stats.tsx
--- a/src/components/profile/profile-stats.tsx
+++ b/src/components/profile/profile-stats.tsx
@@ -1,10 +1,11 @@
/**
* ProfileStats - Renders AT Protocol stats (followers, following, posts) and Bluesky link.
- * Extracted from ProfileHeader to keep components under ~150 lines.
+ * Used as a sub-component within the "AT Protocol" stats section of ProfileHeader.
* @see specs/prd-web.md Section M8
*/
import { Users, ArrowSquareOut } from '@phosphor-icons/react'
+import { formatCount } from '@/lib/format-count'
import type { UserProfile } from '@/lib/api/types'
interface ProfileStatsProps {
@@ -14,16 +15,20 @@
export function ProfileStats({ profile, handle }: ProfileStatsProps) {
return (
- <>
- {/* AT Protocol stats */}
-
-
-
- {profile.followersCount} {profile.followersCount === 1 ? 'follower' : 'followers'}
+
+
+
+
+ {formatCount(profile.followersCount)}{' '}
+ {profile.followersCount === 1 ? 'follower' : 'followers'}
- {profile.followsCount} following
- {profile.atprotoPostsCount} AT Proto posts
-
+
+
+ {formatCount(profile.followsCount)} following
+
+
+ {formatCount(profile.atprotoPostsCount)} AT Proto posts
+
{/* Bluesky link */}
{profile.hasBlueskyProfile && (
@@ -31,12 +36,12 @@
href={`https://bsky.app/profile/${handle}`}
target="_blank"
rel="noopener noreferrer"
- className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
+ className="mt-1 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
- View on Bluesky
+ bsky.app/profile/{handle}
)}
- >
+
)
}
diff --git a/src/app/u/[handle]/page.test.tsx b/src/app/u/[handle]/page.test.tsx
--- a/src/app/u/[handle]/page.test.tsx
+++ b/src/app/u/[handle]/page.test.tsx
@@ -89,7 +89,7 @@
render()
// Alice's mock data has globalActivity (communityCount: 2)
await waitFor(() => {
- expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument()
+ expect(screen.getByText('Barazo-wide')).toBeInTheDocument()
})
})
@@ -103,7 +103,7 @@
it('renders Bluesky link', async () => {
render()
await waitFor(() => {
- const link = screen.getByRole('link', { name: /view on bluesky/i })
+ const link = screen.getByRole('link', { name: /bsky\.app\/profile\/alice\.bsky\.social/i })
expect(link).toHaveAttribute('href', 'https://bsky.app/profile/alice.bsky.social')
})
})