# 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 (
)
}
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 `` 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 (
{src && (
)}
{initial}
);
}
```
- [ ] **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 `` 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 (
{emoji}
{title && (
{title}
)}
{message}
{ctaLabel && ctaHref && (
{ctaLabel}
)}
);
}
```
- [ ] **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
```
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 (
);
}
```
- [ ] **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 122–148):
```tsx
function LandingPage() {
return (
{/* Ambient orbs */}
{/* Hero card */}
{/* Gloss sweep */}
Welcome to Thoughts ✨
A federated social network for short-form thoughts.
Connect with the Fediverse.
Login
Register
{/* Fediverse badge */}
Works with Mastodon, Pixelfed & more
);
}
```
- [ ] **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) ? (
{part}
) : (
part
)
);
}
```
- [ ] **Step 2: Add hover lift to the `` element**
Find the `` line and change it to:
```tsx
```
- [ ] **Step 3: Replace the Reply `` with a pill variant**
Find the CardFooter Button:
```tsx
setIsReplyOpen(!isReplyOpen)}
>
```
Replace with:
```tsx
setIsReplyOpen(!isReplyOpen)}
>
```
- [ ] **Step 4: Apply hashtag coloring to local thought content**
Find the local thought content ``:
```tsx
{thought.author.local ? (
{thought.content}
```
Replace with:
```tsx
{thought.author.local ? (
{renderWithHashtags(thought.content)}
```
- [ ] **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 && (
setIsReplyOpen(false)}
/>
)}
```
Replace with:
```tsx
{isReplyOpen && (
setIsReplyOpen(false)}
/>
)}
```
- [ ] **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
```
Replace with:
```tsx
```
- [ ] **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 (
🏷
Popular Tags
No tags yet.
);
}
return (
🏷
Popular Tags
{tags.map((tag, i) => (
{i < 2 ? "🔥 " : "#"}{tag}
))}
);
}
```
- [ ] **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 (
{friends.map((friend) => (
{friend.displayName || friend.username}
@{friend.username}
following
))}
);
}
```
- [ ] **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 (
✨
Community
{count === null ? (
Could not load member count.
) : count === 0 ? (
Be the first to join!
) : (
)}
);
}
```
- [ ] **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(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 (
{optimisticFollowing ? (
<> Unfollow>
) : (
<> Follow>
)}
)
}
```
- [ ] **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