Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 26 additions & 27 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,54 +21,52 @@ Animata is a free, open-source library of animated React components built with N

## Changelog rule — ALWAYS update this

The changelog lives at `content/docs/changelog/` — one MDX file per month plus an index.
Release notes use **four tiers**. Full process, examples, and checklist: [contributing changelog](/docs/contributing/changelog).

### Structure
| Tier | Where | Length |
| --- | --- | --- |
| 1 — Site overview | `content/docs/changelog/index.mdx` | One line per month |
| 2 — Monthly release | `content/docs/changelog/YYYY-MM.mdx` | Paragraph + component cards |
| 3 — Category index | `content/docs/{category}/index.mdx` | Table: date · component · change |
| 4 — Component doc | `content/docs/{category}/{name}.mdx` → `## Changelog` | Dated bullets, most detail |

```text
content/docs/changelog/
index.mdx # overview page — shows 2 most recent months + table linking all others
2026-04.mdx # April 2026 (newest first)
2026-03.mdx
...
index.mdx # tier 1 — recent months + table of all months
YYYY-MM.mdx # tier 2 — one file per month (newest first)
content/docs/{category}/
index.mdx # tier 3 — Recent changes table
{name}.mdx # tier 4 — ## Changelog at bottom
```

### When adding a new month

1. Create `content/docs/changelog/YYYY-MM.mdx` with this frontmatter:
```yaml
---
title: Month YYYY
description: One-line summary of the main themes.
date: YYYY-MM-DD
---
```
2. Write the content as prose, not bullet lists. Each new component gets a short paragraph — what it is, when you'd use it, anything notable about the implementation.
3. Add the new month to `config/docs.ts` under the Changelog items array (newest first).
4. Update `content/docs/changelog/index.mdx`: promote the new month to "Recent releases" and move the oldest of the current two into the table.
1. Create `content/docs/changelog/YYYY-MM.mdx` with frontmatter (`title`, `description`, `date`).
2. Write tier-2 prose and `<ChangeLogEntry>` blocks — see [contributing changelog](/docs/contributing/changelog).
3. Add the month to `config/docs.ts` under Changelog (newest first).
4. Update tier 1 in `changelog/index.mdx`: promote to **Recent releases**, demote oldest to **All releases**.

### When updating an existing month

Add to the relevant `YYYY-MM.mdx` file. No need to touch the index unless the summary line there is now misleading.
Add to the relevant `YYYY-MM.mdx`. Update tier 3 (category index table) and tier 4 (component `## Changelog`). Touch tier 1 only if the month summary line is now misleading.

### What warrants a changelog entry

| Action | Update changelog? |
|---|---|
| New component | Yes — new section in the current month file |
| Landing/docs UI update | Yes — brief paragraph |
| Major dependency upgrade | Yes — explain what changed and why it matters |
| User-visible bug fix | Yes — one sentence is fine |
| Action | Update? |
| --- | --- |
| New component | All four tiers |
| User-visible update or fix | Tiers 2–4; tier 1 if it shapes the month |
| Landing/docs UI (site-wide) | Tiers 1–2 |
| Major dependency upgrade | Tiers 1–2 |
| Dependency patch bumps | No |
| CI / GitHub Actions only | No |
| Typo fix in docs | No |

### Writing style

- Past tense. "Added X" not "We're excited to announce X."
- No emojis, no prefix symbols (no ✨ 🛠 🐛).
- Each component gets a description of what it does and when you'd use it — not just its name.
- If a fix has a root cause worth knowing, say so briefly.
- No emojis, no prefix symbols.
- Detail increases down the tiers — one line at the top, bullets at the component doc.

## File map (quick reference)

Expand Down Expand Up @@ -97,6 +95,7 @@ content/docs/
contributing/
category-glyphs.mdx # Category tile SVG guide (published)
category-glyphs-spec.md # Full spec — keep in sync; not under animata/
changelog.mdx # Four-tier release notes process
config/
docs.ts # Sidebar nav config — register new component categories here
styles/globals.css # Tailwind v4 theme tokens
Expand Down
246 changes: 246 additions & 0 deletions animata/card/card-stack-profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"use client";

import type { LucideIcon } from "lucide-react";
import { Heart, MessageCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";

import CardStack, {
type CardStackItem,
type CardStackLayerMotion,
} from "@/animata/card/card-stack";
import { CardStackMaskDefs } from "@/components/shapes/card-stack-mask-defs";
import { cn } from "@/lib/utils";

export const CARD_STACK_MASK_IDS = [
"cardstack_mask_ellipse-1",
"cardstack_mask_flower-14",
"cardstack_mask_flower-1",
"cardstack_mask_misc-5",
] as const;

export type CardStackProfileMaskId = (typeof CARD_STACK_MASK_IDS)[number];

export type CardStackProfileMediaAspect = "fill" | "square" | "4/5" | "3/4" | "16/10";

export interface CardStackProfileItem extends CardStackItem {
image: string;
title: string;
tagline: string;
counts?: {
like: number;
comment: number;
};
maskId: CardStackProfileMaskId;
}

const CARD_STACK_MASK_STYLE = {
maskSize: "cover",
maskPosition: "center",
maskRepeat: "no-repeat",
} as const;

const MEDIA_ASPECT_CLASS: Record<Exclude<CardStackProfileMediaAspect, "fill">, string> = {
square: "aspect-square",
"4/5": "aspect-[4/5]",
"3/4": "aspect-[3/4]",
"16/10": "aspect-[16/10]",
};

export function CardStackProfileMasks({ className }: { className?: string }) {
return <CardStackMaskDefs className={cn("pointer-events-none absolute", className)} />;
}

export function CardStackProfileLiveRegion({
className,
item,
}: {
className?: string;
item: CardStackProfileItem | undefined;
}) {
return (
<p className={cn("sr-only", className)} aria-live="polite" aria-atomic="true">
{item ? `Showing ${item.title}, ${item.tagline}` : "No cards available"}
</p>
);
}

export function CardStackProfileHeader({ className, ...props }: ComponentProps<"header">) {
return <header className={cn("flex items-center gap-2 p-4", className)} {...props} />;
}

export function CardStackProfileAvatar({
src,
className,
...props
}: ComponentProps<"div"> & { src: string }) {
return (
<div
className={cn(
"relative size-7 shrink-0 overflow-hidden rounded-full ring-2 ring-border",
className,
)}
{...props}
>
<img
src={src}
alt=""
aria-hidden
width={28}
height={28}
decoding="async"
className="size-full object-cover"
/>
</div>
);
}

export function CardStackProfileMeta({
title,
tagline,
className,
...props
}: ComponentProps<"div"> & { title: string; tagline: string }) {
return (
<div className={cn("flex min-w-0 flex-col items-start gap-0.5", className)} {...props}>
<h3 className="truncate text-xs font-medium leading-none tracking-wide text-foreground">
{title}
</h3>
<p className="truncate overflow-visible text-[10px] leading-none text-muted-foreground">
{tagline}
</p>
</div>
);
}

export function CardStackProfileMedia({
src,
alt,
maskId,
aspect = "square",
className,
style,
...props
}: Omit<ComponentProps<"img">, "src" | "alt"> & {
src: string;
alt: string;
maskId: CardStackProfileMaskId;
aspect?: CardStackProfileMediaAspect;
}) {
const maskStyle = {
...CARD_STACK_MASK_STYLE,
maskImage: `url(#${maskId})`,
WebkitMaskImage: `url(#${maskId})`,
WebkitMaskSize: CARD_STACK_MASK_STYLE.maskSize,
WebkitMaskPosition: CARD_STACK_MASK_STYLE.maskPosition,
WebkitMaskRepeat: CARD_STACK_MASK_STYLE.maskRepeat,
...style,
};

return (
<figure
className={cn(
"mx-auto shrink-0 overflow-hidden",
aspect === "square" && "aspect-square size-52",
aspect === "fill" && "min-h-0 w-full flex-1",
aspect !== "square" && aspect !== "fill" && MEDIA_ASPECT_CLASS[aspect],
className,
)}
>
<img
src={src}
alt={alt}
width={208}
height={208}
decoding="async"
draggable={false}
className="size-full object-cover"
style={maskStyle}
{...props}
/>
</figure>
);
}

export function CardStackProfileFooter({ className, ...props }: ComponentProps<"footer">) {
return (
<footer
className={cn("flex items-center gap-3 px-4 pb-4 pt-1 text-sm text-foreground", className)}
{...props}
/>
);
}

export function CardStackProfileMetric({
icon: Icon,
label,
value,
className,
...props
}: ComponentProps<"span"> & { icon: LucideIcon; label: string; value: number | string }) {
return (
<span className={cn("inline-flex items-center gap-1", className)} {...props}>
<Icon aria-hidden className="size-6 shrink-0" />
<span className="sr-only">{label}: </span>
{value}
</span>
);
}

interface CardStackProfileCardProps {
item: CardStackProfileItem;
index: number;
layer: CardStackLayerMotion;
className?: string;
footerTrailing?: ReactNode;
showMetrics?: boolean;
}

/** Profile-style card for demos and Storybook — not part of the core stack API. */
export function CardStackProfileCard({
item,
index,
layer,
className,
footerTrailing,
showMetrics = true,
}: CardStackProfileCardProps) {
return (
<CardStack.Card
layer={layer}
stackIndex={index}
className={cn(
"flex flex-col gap-3 rounded-4xl bg-linear-to-br from-pink-100 via-white to-white p-0 shadow-xl ring-1 ring-border",
"dark:from-pink-950 dark:via-card dark:to-card",
className,
)}
>
<CardStackProfileHeader>
<CardStackProfileAvatar src={item.image} />
<CardStackProfileMeta title={item.title} tagline={item.tagline} />
</CardStackProfileHeader>

<CardStackProfileMedia
src={item.image}
alt={`Portrait of ${item.title}`}
maskId={item.maskId}
/>

{showMetrics || footerTrailing ? (
<CardStackProfileFooter>
{showMetrics ? (
<>
<CardStackProfileMetric icon={Heart} label="Likes" value={item.counts?.like ?? 0} />
<CardStackProfileMetric
icon={MessageCircle}
label="Comments"
value={item.counts?.comment ?? 0}
className="ms-1"
/>
</>
) : null}
{footerTrailing}
</CardStackProfileFooter>
) : null}
</CardStack.Card>
);
}
Loading