Files
thoughts/docs/superpowers/plans/2026-05-16-frutiger-aero-redesign.md

1199 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Thoughts Frontend — Frutiger Aero Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Apply the Frutiger Aero aesthetic throughout `thoughts-frontend` — glass panels, gloss sweeps, Aero shimmer, gradient avatars, and delightful interaction moments (particle bursts, shake+fade, slide-in forms).
**Architecture:** Component-by-component update. CSS keyframes and utility classes go in `globals.css` first (foundation). UI primitives (badge, skeleton) are updated next. Then page-level components (header, landing). Then feed components (cards, widgets). Then interaction moments (follow burst, delete animation). Every task ends with a build check and a commit.
**Tech Stack:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS v4, shadcn/ui, Bun
**Build check command (run after every task):**
```bash
cd thoughts-frontend && bun run build
```
**Spec:** `docs/superpowers/specs/2026-05-16-thoughts-frutiger-aero-redesign-design.md`
---
## Pre-flight: verify current build passes
- [ ] Run `cd thoughts-frontend && bun run build` — must be green before starting
---
## Task 1: CSS keyframes and utility classes
**Files:**
- Modify: `thoughts-frontend/app/globals.css`
Add after the last `@layer components { }` block.
- [ ] **Step 1: Append keyframes and utilities to `globals.css`**
Add this block at the end of the file:
```css
/* ── Frutiger Aero interaction keyframes ── */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
max-height: 0;
overflow: hidden;
}
to {
opacity: 1;
transform: translateY(0);
max-height: 300px;
overflow: hidden;
}
}
@keyframes shake {
0%, 100% { transform: translateX(0) rotate(0deg); }
15% { transform: translateX(-4px) rotate(-1.5deg); }
30% { transform: translateX(4px) rotate(1.5deg); }
45% { transform: translateX(-3px) rotate(-1deg); }
60% { transform: translateX(3px) rotate(1deg); }
75% { transform: translateX(-1px) rotate(-0.5deg); }
}
@keyframes fadeOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.9) translateY(8px); }
}
@keyframes floatBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes shimmerAero {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
@layer components {
.animate-slide-down {
animation: slideDown 0.22s ease-out forwards;
}
.animate-shake {
animation: shake 0.45s ease-out;
}
.animate-fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
.animate-float-bob {
animation: floatBob 2.8s ease-in-out infinite;
}
/* Aero-tinted shimmer for skeleton loaders */
.shimmer-aero {
background: linear-gradient(
90deg,
rgba(96, 165, 250, 0.12) 25%,
rgba(96, 165, 250, 0.30) 50%,
rgba(96, 165, 250, 0.12) 75%
);
background-size: 800px 100%;
animation: shimmerAero 1.5s infinite linear;
}
/* Widget title icon badges */
.widget-icon {
width: 22px;
height: 22px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.widget-icon-blue {
background: linear-gradient(135deg, #60a5fa, #2563eb);
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
.widget-icon-green {
background: linear-gradient(135deg, #6ee7b7, #10b981);
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
.widget-icon-purple {
background: linear-gradient(135deg, #c4b5fd, #7c3aed);
box-shadow: 0 2px 4px rgba(124, 58, 237, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
/* Landing page ambient orbs */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.45;
pointer-events: none;
}
/* Gradient avatar fallback */
.avatar-gradient {
background: linear-gradient(135deg, #60a5fa, #34d399);
box-shadow: 0 0 0 2px white, 0 0 0 3.5px rgba(59, 130, 246, 0.45);
}
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.animate-slide-down { animation: none; }
.animate-shake { animation: none; }
.animate-fade-out { animation: none; }
.animate-float-bob { animation: none; }
.shimmer-aero {
animation: none;
background: rgba(96, 165, 250, 0.18);
}
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
Expected: build succeeds with no errors.
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/app/globals.css
git commit -m "feat: add FA keyframes and utility classes to globals.css"
```
---
## Task 2: Badge `branded` and `trending` variants
**Files:**
- Modify: `thoughts-frontend/components/ui/badge.tsx`
- [ ] **Step 1: Add variants to `badgeVariants` in `badge.tsx`**
Replace the `variants` object inside `cva(...)`:
```tsx
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm",
outline: "text-foreground glossy-effect bottom text-shadow-sm",
branded:
"border border-primary/20 bg-primary/8 text-primary font-semibold hover:bg-primary/15 hover:scale-105 transition-transform cursor-pointer",
trending:
"border border-red-300/30 bg-gradient-to-r from-orange-500/10 to-red-500/8 text-red-600 font-semibold hover:from-orange-500/18 hover:to-red-500/14 hover:scale-105 transition-transform cursor-pointer",
},
},
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/ui/badge.tsx
git commit -m "feat: add branded and trending badge variants"
```
---
## Task 3: Skeleton Aero shimmer
**Files:**
- Modify: `thoughts-frontend/components/ui/skeleton.tsx`
- [ ] **Step 1: Read current `skeleton.tsx`**
```bash
cat thoughts-frontend/components/ui/skeleton.tsx
```
- [ ] **Step 2: Replace the file with Aero shimmer version**
```tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("rounded-md shimmer-aero", className)}
{...props}
/>
)
}
export { Skeleton }
```
- [ ] **Step 3: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 4: Commit**
```bash
git add thoughts-frontend/components/ui/skeleton.tsx
git commit -m "feat: apply Aero shimmer to skeleton loader"
```
---
## Task 4: UserAvatar — gradient fallback + glow ring
**Files:**
- Modify: `thoughts-frontend/components/user-avatar.tsx`
Current: fallback is a generic `<User>` icon, border is `border-primary/50`.
- [ ] **Step 1: Update `user-avatar.tsx`**
```tsx
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
interface UserAvatarProps {
src?: string | null;
alt?: string | null;
className?: string;
}
export function UserAvatar({ src, alt, className }: UserAvatarProps) {
const initial = alt?.trim()[0]?.toUpperCase() ?? "?";
return (
<Avatar className={cn("avatar-gradient", className)}>
{src && (
<AvatarImage
className="object-cover object-center"
src={src}
alt={alt ?? "User avatar"}
/>
)}
<AvatarFallback className="avatar-gradient text-white font-bold text-sm">
{initial}
</AvatarFallback>
</Avatar>
);
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/user-avatar.tsx
git commit -m "feat: gradient avatar fallback with initials and glow ring"
```
---
## Task 5: EmptyState redesign
**Files:**
- Modify: `thoughts-frontend/components/empty-state.tsx`
Current: renders a single `<p>` with the message string.
- [ ] **Step 1: Rewrite `empty-state.tsx`**
```tsx
import Link from "next/link";
interface EmptyStateProps {
emoji?: string;
title?: string;
message: string;
ctaLabel?: string;
ctaHref?: string;
className?: string;
}
export function EmptyState({
emoji = "💭",
title,
message,
ctaLabel,
ctaHref,
className = "",
}: EmptyStateProps) {
return (
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
<span className="text-4xl animate-float-bob select-none" role="img" aria-hidden>
{emoji}
</span>
{title && (
<p className="font-bold text-base text-foreground text-shadow-sm">{title}</p>
)}
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">{message}</p>
{ctaLabel && ctaHref && (
<Link
href={ctaHref}
className="mt-2 inline-flex items-center gap-1.5 px-5 py-2 rounded-full text-sm font-bold text-white fa-gradient-blue shadow-fa-md glossy-effect relative overflow-hidden"
>
{ctaLabel}
</Link>
)}
</div>
);
}
```
- [ ] **Step 2: Update all call sites to pass the new props**
Search for existing usages:
```bash
grep -rn "EmptyState" thoughts-frontend/app --include="*.tsx"
```
For each usage, add an `emoji` and `title` appropriate to the context. For example in `app/page.tsx`:
```tsx
<EmptyState
emoji="💭"
title="Your feed is quiet"
message="Follow some people to fill your feed with thoughts."
ctaLabel="Discover people ✨"
ctaHref="/users/all"
/>
```
For search (`app/search/page.tsx`) — check the file and use `emoji="🔍" title="No results"`.
For tags — use `emoji="🏷" title="No thoughts here yet"`.
- [ ] **Step 3: Build check**
```bash
cd thoughts-frontend && bun run build
```
Expected: no TypeScript errors — `message` is still required, other props are optional.
- [ ] **Step 4: Commit**
```bash
git add thoughts-frontend/components/empty-state.tsx thoughts-frontend/app
git commit -m "feat: redesign EmptyState with floating emoji and optional CTA"
```
---
## Task 6: Header — logo bubble + pill buttons
**Files:**
- Modify: `thoughts-frontend/components/header.tsx`
Current: plain text "Thoughts", flat Login/Register buttons.
- [ ] **Step 1: Rewrite `header.tsx`**
```tsx
"use client";
import { useAuth } from "@/hooks/use-auth";
import Link from "next/link";
import { Button } from "./ui/button";
import { UserNav } from "./user-nav";
import { MainNav } from "./main-nav";
export function Header() {
const { token } = useAuth();
return (
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-white/20 bg-background/80 glass-effect glossy-effect bottom rounded-none shadow-fa-md">
<div className="container flex h-14 items-center px-2">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
<div
className="w-8 h-8 flex items-center justify-center fa-gradient-blue shadow-fa-sm glossy-effect relative overflow-hidden"
style={{ borderRadius: "50% 50% 50% 10px" }}
>
<span className="text-base relative z-10 select-none">💭</span>
</div>
<span className="hidden sm:inline-block font-bold text-primary text-shadow-sm">
Thoughts
</span>
</Link>
<MainNav />
<div className="flex flex-1 items-center justify-end space-x-2">
{token ? (
<UserNav />
) : (
<>
<Button asChild size="sm" variant="outline" className="rounded-full">
<Link href="/login">Login</Link>
</Button>
<Button asChild size="sm" className="rounded-full">
<Link href="/register">Register</Link>
</Button>
</>
)}
</div>
</div>
</header>
);
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/header.tsx
git commit -m "feat: add logo bubble and pill buttons to header"
```
---
## Task 7: Landing page — orbs, deeper glass, fediverse badge
**Files:**
- Modify: `thoughts-frontend/app/page.tsx` (the `LandingPage` function only)
- [ ] **Step 1: Replace the `LandingPage` function**
Find and replace the entire `LandingPage` function (lines 122148):
```tsx
function LandingPage() {
return (
<div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Ambient orbs */}
<div
className="orb"
style={{
width: 280, height: 280,
background: "radial-gradient(circle, #ffffff 0%, #87ceeb 60%, transparent 100%)",
top: "-80px", left: "-60px",
}}
/>
<div
className="orb"
style={{
width: 220, height: 220,
background: "radial-gradient(circle, #b2f5ea 0%, #48bb78 60%, transparent 100%)",
bottom: "-40px", right: "5%",
}}
/>
<div
className="orb"
style={{
width: 160, height: 160,
background: "radial-gradient(circle, #e0f2fe 0%, #38bdf8 60%, transparent 100%)",
top: "35%", left: "65%",
}}
/>
{/* Hero card */}
<div
className="container mx-auto max-w-lg p-4 sm:p-6 text-center relative z-10"
style={{
background: "rgba(255,255,255,0.28)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.55)",
borderRadius: "20px",
boxShadow: "0 8px 32px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.6)",
}}
>
{/* Gloss sweep */}
<div
aria-hidden
style={{
position: "absolute", top: 0, left: 0, right: 0, height: "55%",
background: "linear-gradient(180deg, rgba(255,255,255,0.38) 0%, transparent 100%)",
borderRadius: "20px 20px 0 0",
pointerEvents: "none",
}}
/>
<h1
className="text-5xl font-bold relative"
style={{ textShadow: "0 2px 4px rgba(255,255,255,0.6), 0 1px 2px rgba(0,0,0,0.1)" }}
>
Welcome to Thoughts
</h1>
<p className="text-muted-foreground mt-3 relative">
A federated social network for short-form thoughts.<br />
Connect with the Fediverse.
</p>
<div className="mt-8 flex justify-center gap-4 relative">
<Button asChild className="rounded-full px-7">
<Link href="/login">Login</Link>
</Button>
<Button asChild variant="secondary" className="rounded-full px-7">
<Link href="/register">Register</Link>
</Button>
</div>
{/* Fediverse badge */}
<div className="mt-5 relative flex justify-center">
<span
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs text-muted-foreground"
style={{
background: "rgba(255,255,255,0.3)",
border: "1px solid rgba(255,255,255,0.5)",
}}
>
<span
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
style={{ boxShadow: "0 0 4px #34d399" }}
/>
Works with Mastodon, Pixelfed &amp; more
</span>
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/app/page.tsx
git commit -m "feat: redesign landing page with ambient orbs and fediverse badge"
```
---
## Task 8: Thought card — hover lift, reply button pill, hashtag coloring
**Files:**
- Modify: `thoughts-frontend/components/thought-card.tsx`
- [ ] **Step 1: Add `renderWithHashtags` helper before the component**
Add this function just above the `ThoughtCard` function declaration:
```tsx
function renderWithHashtags(content: string) {
return content.split(/(#\w+)/g).map((part, i) =>
/^#\w+$/.test(part) ? (
<span key={i} className="text-primary font-medium">
{part}
</span>
) : (
part
)
);
}
```
- [ ] **Step 2: Add hover lift to the `<Card>` element**
Find the `<Card className="mt-2">` line and change it to:
```tsx
<Card className="mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">
```
- [ ] **Step 3: Replace the Reply `<Button>` with a pill variant**
Find the CardFooter Button:
```tsx
<Button
variant="ghost"
size="sm"
onClick={() => setIsReplyOpen(!isReplyOpen)}
>
```
Replace with:
```tsx
<Button
variant="ghost"
size="sm"
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
onClick={() => setIsReplyOpen(!isReplyOpen)}
>
```
- [ ] **Step 4: Apply hashtag coloring to local thought content**
Find the local thought content `<p>`:
```tsx
{thought.author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm">
{thought.content}
</p>
```
Replace with:
```tsx
{thought.author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm">
{renderWithHashtags(thought.content)}
</p>
```
- [ ] **Step 5: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 6: Commit**
```bash
git add thoughts-frontend/components/thought-card.tsx
git commit -m "feat: card hover lift, pill reply button, hashtag coloring"
```
---
## Task 9: Thought card — reply form slide-in animation
**Files:**
- Modify: `thoughts-frontend/components/thought-card.tsx`
- [ ] **Step 1: Wrap the reply form with the slide-in animation div**
Find the `{isReplyOpen && (` block:
```tsx
{isReplyOpen && (
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
<ThoughtForm
replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)}
/>
</div>
)}
```
Replace with:
```tsx
{isReplyOpen && (
<div className="animate-slide-down border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
<ThoughtForm
replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)}
/>
</div>
)}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/thought-card.tsx
git commit -m "feat: reply form slide-in animation"
```
---
## Task 10: Thought card — delete shake and fade animation
**Files:**
- Modify: `thoughts-frontend/components/thought-card.tsx`
- [ ] **Step 1: Add `deletingState` to component state**
Find the existing state declarations near the top of `ThoughtCard`:
```tsx
const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false);
```
Add after them:
```tsx
const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
```
- [ ] **Step 2: Rewrite `handleDelete` to animate before deleting**
Find the existing `handleDelete`:
```tsx
const handleDelete = async () => {
try {
await deleteThought(thought.id);
toast.success("Thought deleted successfully.");
} catch (error) {
console.error("Failed to delete thought:", error);
toast.error("Failed to delete thought.");
} finally {
setIsAlertOpen(false);
}
};
```
Replace with:
```tsx
const handleDelete = async () => {
setIsAlertOpen(false);
setDeletingState("shaking");
await new Promise((r) => setTimeout(r, 450));
setDeletingState("fading");
await new Promise((r) => setTimeout(r, 300));
try {
await deleteThought(thought.id);
toast.success("Thought deleted.");
} catch (error) {
console.error("Failed to delete thought:", error);
setDeletingState("idle");
toast.error("Failed to delete thought.");
}
};
```
- [ ] **Step 3: Apply animation classes to the Card**
Find the Card element (already modified in Task 8):
```tsx
<Card className="mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">
```
Replace with:
```tsx
<Card
className={cn(
"mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg",
deletingState === "shaking" && "animate-shake",
deletingState === "fading" && "animate-fade-out pointer-events-none"
)}
>
```
- [ ] **Step 4: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 5: Commit**
```bash
git add thoughts-frontend/components/thought-card.tsx
git commit -m "feat: delete thought shake and fade animation"
```
---
## Task 11: Popular Tags widget — icon badge + branded/trending pills
**Files:**
- Modify: `thoughts-frontend/components/popular-tags.tsx`
- [ ] **Step 1: Rewrite `popular-tags.tsx`**
```tsx
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { getPopularTags } from "@/lib/api";
export async function PopularTags() {
const tags = await getPopularTags().catch(() => []);
if (tags.length === 0) {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-blue">🏷</span>
Popular Tags
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<p className="text-center text-sm text-muted-foreground py-4">No tags yet.</p>
</CardContent>
</Card>
);
}
return (
<Card className="p-4">
<CardHeader className="p-0 pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-blue">🏷</span>
Popular Tags
</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 p-0">
{tags.map((tag, i) => (
<Link href={`/tags/${tag}`} key={tag}>
<Badge variant={i < 2 ? "trending" : "branded"}>
{i < 2 ? "🔥 " : "#"}{tag}
</Badge>
</Link>
))}
</CardContent>
</Card>
);
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/popular-tags.tsx
git commit -m "feat: Popular Tags widget with icon badge and branded/trending pills"
```
---
## Task 12: Top Friends widget — icon badge, gradient avatars, handle
**Files:**
- Modify: `thoughts-frontend/components/top-friends.tsx`
- [ ] **Step 1: Rewrite `top-friends.tsx`**
```tsx
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UserAvatar } from "./user-avatar";
import { getTopFriends } from "@/lib/api";
import { cookies } from "next/headers";
interface TopFriendsProps {
username: string;
}
export async function TopFriends({ username }: TopFriendsProps) {
const token = (await cookies()).get("auth_token")?.value ?? null;
const data = await getTopFriends(username, token).catch(() => ({ topFriends: [] }));
const friends = data.topFriends;
if (friends.length === 0) return null;
return (
<Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-3">
<CardTitle id="top-friends__title" className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-green">👥</span>
Top Friends
</CardTitle>
</CardHeader>
<CardContent id="top-friends__content" className="p-0 space-y-1">
{friends.map((friend) => (
<Link
id={`top-friends__link-${friend.id}`}
href={`/users/${friend.username}`}
key={friend.id}
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<UserAvatar src={friend.avatarUrl} alt={friend.displayName || friend.username} />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate text-shadow-sm">
{friend.displayName || friend.username}
</span>
<span className="text-[10px] text-muted-foreground truncate">
@{friend.username}
</span>
</div>
<span className="ml-auto shrink-0 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-600">
following
</span>
</Link>
))}
</CardContent>
</Card>
);
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/top-friends.tsx
git commit -m "feat: Top Friends widget with icon badge and gradient avatars"
```
---
## Task 13: Community widget (UsersCount)
**Files:**
- Modify: `thoughts-frontend/components/users-count.tsx`
Note: current file has a bug — `import { Link } from "lucide-react"` should be `import Link from "next/link"`. Fix this too.
- [ ] **Step 1: Rewrite `users-count.tsx`**
```tsx
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import { getAllUsersCount } from "@/lib/api";
export async function UsersCount() {
const usersCount = await getAllUsersCount().catch(() => null);
const count = usersCount?.count ?? null;
return (
<Card className="p-4">
<CardHeader className="p-0 pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-purple"></span>
Community
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{count === null ? (
<p className="text-sm text-muted-foreground text-center py-2">
Could not load member count.
</p>
) : count === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
Be the first to join!
</p>
) : (
<div
className="rounded-xl p-3 text-center glossy-effect relative overflow-hidden"
style={{
background: "rgba(255,255,255,0.4)",
border: "1px solid rgba(255,255,255,0.6)",
}}
>
<div
className="text-3xl font-extrabold leading-none"
style={{
background: "linear-gradient(135deg, #2563eb, #06b6d4)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
{count}
</div>
<div className="text-[10px] uppercase tracking-widest text-muted-foreground mt-1 font-semibold">
members
</div>
</div>
)}
</CardContent>
</Card>
);
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/users-count.tsx
git commit -m "feat: Community widget with gradient stat cell, fix Link import bug"
```
---
## Task 14: Follow button — canvas particle burst
**Files:**
- Modify: `thoughts-frontend/components/follow-button.tsx`
- [ ] **Step 1: Rewrite `follow-button.tsx`**
```tsx
"use client"
import { useOptimistic, useRef } from "react"
import { followUser, unfollowUser } from "@/app/actions/social"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { UserPlus, UserMinus } from "lucide-react"
interface FollowButtonProps {
username: string
isInitiallyFollowing: boolean
}
const BURST_COLORS = ["#2563eb", "#06b6d4", "#10b981", "#f59e0b", "#a855f7", "#ef4444"]
function burstParticles(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d")
if (!ctx) return
const cx = canvas.width / 2
const cy = canvas.height / 2
const particles = Array.from({ length: 14 }, (_, i) => {
const angle = (i / 14) * Math.PI * 2
const speed = 2.5 + Math.random() * 2
return {
x: cx,
y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 3 + Math.random() * 3,
color: BURST_COLORS[i % BURST_COLORS.length],
life: 1,
}
})
function frame() {
ctx!.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.08
p.life -= 0.03
if (p.life > 0) {
alive = true
ctx!.globalAlpha = p.life
ctx!.fillStyle = p.color
ctx!.beginPath()
ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx!.fill()
}
}
ctx!.globalAlpha = 1
if (alive) requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
}
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
const canvasRef = useRef<HTMLCanvasElement>(null)
async function handleClick() {
const next = !optimisticFollowing
setOptimisticFollowing(next)
if (next && canvasRef.current) {
burstParticles(canvasRef.current)
}
try {
await (next ? followUser(username) : unfollowUser(username))
} catch {
setOptimisticFollowing(!next)
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
}
}
return (
<div className="relative inline-block">
<canvas
ref={canvasRef}
width={160}
height={80}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
aria-hidden
/>
<Button
onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"}
className="relative rounded-full"
data-following={optimisticFollowing}
>
{optimisticFollowing ? (
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
) : (
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
</div>
)
}
```
- [ ] **Step 2: Build check**
```bash
cd thoughts-frontend && bun run build
```
- [ ] **Step 3: Commit**
```bash
git add thoughts-frontend/components/follow-button.tsx
git commit -m "feat: canvas particle burst on follow"
```
---
## Final: visual verification checklist
Start the dev server and walk through each surface:
```bash
cd thoughts-frontend && bun run dev
```
- [ ] **Landing page** — background image visible through orbs, hero card is glassy, buttons are pill-shaped, fediverse badge shows
- [ ] **Header** — 💭 logo bubble, glass blur on scroll, pill Login/Register
- [ ] **Feed** — cards have glass treatment, hover lifts card, hashtags are blue
- [ ] **Reply** — clicking Reply slides form in smoothly, clicking Cancel hides it
- [ ] **Delete** — confirm in alert dialog → card shakes → card fades → gone
- [ ] **Follow button** — particles burst on follow, button turns green
- [ ] **Popular Tags** — 🔥 on top 2 tags, branded blue pills for rest, icon badge
- [ ] **Top Friends** — gradient avatars, username handle, following badge
- [ ] **Community widget** — gradient number text, purple icon badge
- [ ] **Loading skeletons** — blue/teal shimmer instead of grey
- [ ] **Empty states** — floating emoji, friendly copy, glossy CTA where applicable
- [ ] **Mobile** — at 390px width: no sidebar visible, cards full-width, header collapses correctly