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

32 KiB
Raw Blame History

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):

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:

/* ── 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
cd thoughts-frontend && bun run build

Expected: build succeeds with no errors.

  • Step 3: Commit
git add thoughts-frontend/app/globals.css
git commit -m "feat: add FA keyframes and utility classes to globals.css"

Files:

  • Modify: thoughts-frontend/components/ui/badge.tsx

  • Step 1: Add variants to badgeVariants in badge.tsx

Replace the variants object inside cva(...):

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
cd thoughts-frontend && bun run build

Expected: build succeeds.

  • Step 3: Commit
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

cat thoughts-frontend/components/ui/skeleton.tsx
  • Step 2: Replace the file with Aero shimmer version
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
cd thoughts-frontend && bun run build
  • Step 4: Commit
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
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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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
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:

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:

<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
cd thoughts-frontend && bun run build

Expected: no TypeScript errors — message is still required, other props are optional.

  • Step 4: Commit
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
"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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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):

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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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:

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:

<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:

<Button
  variant="ghost"
  size="sm"
  onClick={() => setIsReplyOpen(!isReplyOpen)}
>

Replace with:

<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>:

{thought.author.local ? (
  <p className="whitespace-pre-wrap break-words text-shadow-sm">
    {thought.content}
  </p>

Replace with:

{thought.author.local ? (
  <p className="whitespace-pre-wrap break-words text-shadow-sm">
    {renderWithHashtags(thought.content)}
  </p>
  • Step 5: Build check
cd thoughts-frontend && bun run build
  • Step 6: Commit
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:

{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:

{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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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:

const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false);

Add after them:

const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
  • Step 2: Rewrite handleDelete to animate before deleting

Find the existing handleDelete:

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:

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):

<Card className="mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">

Replace with:

<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
cd thoughts-frontend && bun run build
  • Step 5: Commit
git add thoughts-frontend/components/thought-card.tsx
git commit -m "feat: delete thought shake and fade animation"

Files:

  • Modify: thoughts-frontend/components/popular-tags.tsx

  • Step 1: Rewrite popular-tags.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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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

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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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
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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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

"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
cd thoughts-frontend && bun run build
  • Step 3: Commit
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:

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