Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ae78b9c
feat: add dropdown menu component with keyboard nav and animations
KeenIsHere Apr 26, 2026
194bdee
feat: add animated pricing cards component with hover animations
KeenIsHere Apr 26, 2026
297d911
fix: move pricing cards to correct directory structure
KeenIsHere Apr 26, 2026
09995c9
fix: move mdx doc to correct location and update format
KeenIsHere Apr 26, 2026
a85c24d
fix: recreate animated pricing cards mdx in correct location
KeenIsHere Apr 26, 2026
4989fc3
fix: correct mdx file location and clean up nested dirs
KeenIsHere Apr 26, 2026
be345de
fix: export interfaces and add proper typing to stories
KeenIsHere Apr 26, 2026
6f13cac
fix: add defensive check for plans array and fix tailwind class
KeenIsHere Apr 26, 2026
6c39e4b
fix: publish animated pricing cards docs
KeenIsHere Apr 26, 2026
3d61559
fix(animated-pricing-cards): resolve transform specificity issue
KeenIsHere Apr 28, 2026
f7d7636
fix(animated-pricing-cards): hide decorative checkmark from screen re…
KeenIsHere Apr 28, 2026
fcc45db
docs(animated-pricing-cards): update documentation and add installati…
KeenIsHere Apr 28, 2026
6bc1330
docs: add PR documentation and supervisor review summary
KeenIsHere Apr 28, 2026
ee3b561
fix: resolve Storybook dev script and Google Fonts loading issues
KeenIsHere Apr 28, 2026
edbe9a7
fix: restore aria-hidden on decorative svg and clean up stray files
KeenIsHere Apr 28, 2026
f49d4f9
feat: add feature grid hover reveal section component
KeenIsHere Apr 29, 2026
6e1f115
fix: replace skeleton mdx with proper docs, set published: true
KeenIsHere Apr 29, 2026
277576a
fix: rewrite mdx with proper backticks, remove escaped backticks caus…
KeenIsHere Apr 29, 2026
5b135d2
docs: add credits section with GitHub profile link
KeenIsHere Apr 29, 2026
ef6d39e
docs: add credits section with GitHub profile link
KeenIsHere Apr 29, 2026
a7e535e
feat(primitive): add animated-background-wrapper component
KeenIsHere May 23, 2026
8e556e3
feat: merge feature-grid-hover-reveal component into main
KeenIsHere May 23, 2026
eebd6a3
feat: merge animated-pricing-cards and dropdown-menu components into …
KeenIsHere May 23, 2026
d2d7db4
feat: merge animated-background-wrapper component into main
KeenIsHere May 23, 2026
5f9b7d3
fix(security): move newsletter subscription to server-side API route
KeenIsHere May 23, 2026
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
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1 +1 @@
NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6006
NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6007
21 changes: 20 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,27 @@ const config: StorybookConfig = {
addons: ["@storybook/addon-themes", "@storybook/addon-docs"],
framework: {
name: "@storybook/nextjs",
options: {},
options: {
nextConfigPath: "./next.config.mjs",
},
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate next.config.mjs files:"
fd -a '^next\.config\.mjs$'

echo
echo "Check whether .storybook/next.config.mjs exists:"
if [ -f .storybook/next.config.mjs ]; then
  echo "FOUND: .storybook/next.config.mjs"
else
  echo "NOT FOUND: .storybook/next.config.mjs"
fi

echo
echo "Current nextConfigPath setting in .storybook/main.ts:"
rg -n 'nextConfigPath' .storybook/main.ts -C2

Repository: codse/animata

Length of output: 378


🌐 Web query:

@storybook/nextjs nextConfigPath relative path resolution base directory

πŸ’‘ Result:

@storybook/nextjs: nextConfigPath must be given as an absolute path; Storybook will not do relative-path resolution for you. The documented usage is to compute it from your project’s base directory (commonly the current working directory) before passing it. In Storybook’s Next.js framework docs, nextConfigPath is described as β€œThe absolute path to the next.config.js file” and is explicitly positioned as an option for cases where your next.config.js is not in the project root [1]. The examples show resolving it with Node’s path.resolve(process.cwd, 'next.config.js') from your Storybook main config, meaning the effective β€œbase directory” is whatever directory Storybook is run from / what process.cwd is at config evaluation time [1]. So for your question (relative path resolution + base directory): - Use an absolute path. Don’t pass a relative string. - If you want to build the absolute path from a relative location, anchor that relative location to process.cwd (or otherwise to the directory you consider the project root) just like the docs example: path.resolve(process.cwd, 'next.config.js') [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Contents: .storybook/main.ts (top 60 lines)"
sed -n '1,60p' .storybook/main.ts

Repository: codse/animata

Length of output: 921


Fix nextConfigPath in .storybook/main.ts to point to the repo-root next.config.mjs (absolute path).

Current code uses nextConfigPath: "./next.config.mjs", which resolves to .storybook/next.config.mjs (file doesn’t exist), and @storybook/nextjs requires nextConfigPath to be an absolute path. Update to something like path.resolve(process.cwd, "next.config.mjs") (add import path from "path").

nextConfigPath: "./next.config.mjs",
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.storybook/main.ts around lines 9 - 10, In .storybook/main.ts fix
nextConfigPath to be an absolute path: import path from "path" at the top and
set nextConfigPath using path.resolve(process.cwd(), "next.config.mjs") instead
of the relative "./next.config.mjs" so `@storybook/nextjs` resolves the repo-root
next.config.mjs correctly (update the nextConfigPath entry in the exported
config object).

},
tags: {},
webpackFinal: async (config) => {
// Suppress Google Fonts loading errors during dev
if (config.plugins) {
config.plugins = config.plugins.map((plugin) => {
if (
plugin &&
typeof plugin === "object" &&
"constructor" in plugin &&
plugin.constructor.name === "ProgressPlugin"
) {
return plugin;
}
return plugin;
});
}
return config;
},
};
export default config;
62 changes: 62 additions & 0 deletions animata/navigation/dropdown-menu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HelpCircle, LogOut, Settings, User } from "lucide-react";
import DropdownMenu, { type DropdownMenuProps } from "@/animata/navigation/dropdown-menu";

const meta = {
title: "Navigation/Dropdown Menu",
component: DropdownMenu,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
align: {
control: "select",
options: ["left", "right"],
description: "Dropdown alignment relative to trigger button",
},
triggerLabel: {
control: "text",
description: "Label text for the trigger button",
},
},
} satisfies Meta<typeof DropdownMenu>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
args: {
triggerLabel: "Options",
align: "left",
items: [
{ label: "Profile", icon: <User className="h-4 w-4" /> },
{ label: "Settings", icon: <Settings className="h-4 w-4" /> },
{ label: "Help", icon: <HelpCircle className="h-4 w-4" /> },
{ label: "Sign Out", icon: <LogOut className="h-4 w-4" /> },
],
},
render: (args) => (
<div className="flex h-64 items-center justify-center">
<DropdownMenu {...args} />
</div>
),
};

export const RightAlign: Story = {
args: {
triggerLabel: "Menu",
align: "right",
items: [
{ label: "Profile", icon: <User className="h-4 w-4" /> },
{ label: "Settings", icon: <Settings className="h-4 w-4" /> },
{ label: "Help", icon: <HelpCircle className="h-4 w-4" /> },
{ label: "Sign Out", icon: <LogOut className="h-4 w-4" /> },
],
},
render: (args) => (
<div className="flex h-64 items-center justify-end pr-24">
<DropdownMenu {...args} />
</div>
),
};
168 changes: 168 additions & 0 deletions animata/navigation/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";

import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

export interface MenuItem {
label: string;
icon?: React.ReactNode;
onClick?: () => void;
}

export interface DropdownMenuProps {
items?: MenuItem[];
triggerLabel?: string;
align?: "left" | "right";
}

const defaultItems: MenuItem[] = [
{ label: "Profile" },
{ label: "Settings" },
{ label: "Help" },
{ label: "Sign Out" },
];

export default function DropdownMenu({
items = defaultItems,
triggerLabel = "Options",
align = "left",
}: DropdownMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const prefersReducedMotion = useRef(false);

useEffect(() => {
prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}, []);

useEffect(() => {
if (!isOpen) {
setSelectedIndex(0);
return;
}

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === "Enter") {
Comment on lines +47 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Guard keyboard index math when items is empty.

On Line 50 and Line 53, modulo with items.length breaks for [] and can set selectedIndex to NaN. Add an early return before handling Arrow keys when items.length === 0.

Suggested patch
     const handleKeyDown = (e: KeyboardEvent) => {
+      if (items.length === 0) {
+        if (e.key === "Escape") {
+          e.preventDefault();
+          setIsOpen(false);
+          triggerRef.current?.focus();
+        }
+        return;
+      }
+
       if (e.key === "ArrowDown") {
         e.preventDefault();
         setSelectedIndex((prev) => (prev + 1) % items.length);
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === "Enter") {
const handleKeyDown = (e: KeyboardEvent) => {
if (items.length === 0) {
if (e.key === "Escape") {
e.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === "Enter") {
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@animata/navigation/dropdown-menu.tsx` around lines 47 - 54, handleKeyDown
currently computes new selectedIndex using modulo with items.length which will
throw/produce NaN when items is empty; add an early guard at the top of the
handleKeyDown function to return immediately if items.length === 0 (or
!items.length) before handling "ArrowDown" / "ArrowUp" keys so setSelectedIndex
never receives an invalid value; keep existing behavior for "Enter" as needed
and reference the handleKeyDown function, items array, setSelectedIndex and
selectedIndex state when making the change.

e.preventDefault();
items[selectedIndex]?.onClick?.();
setIsOpen(false);
} else if (e.key === "Escape") {
e.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
}
};

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, selectedIndex, items]);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
menuRef.current &&
triggerRef.current &&
!menuRef.current.contains(e.target as Node) &&
!triggerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};

if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);

const animationProps = prefersReducedMotion.current
? {}
: {
initial: { opacity: 0, translateY: -8 },
animate: { opacity: 1, translateY: 0 },
exit: { opacity: 0, translateY: -8 },
};

return (
<div className="relative inline-block">
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="menu"
aria-expanded={isOpen}
className={cn(
"min-h-11 min-w-11 inline-flex items-center justify-center gap-2 rounded-lg",
"bg-background border border-border px-3 py-2 text-sm font-medium",
"text-foreground transition-colors duration-200",
"hover:bg-muted hover:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"dark:focus:ring-offset-background",
"active:scale-95",
)}
>
{triggerLabel}
<svg
className={cn("h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</button>

<AnimatePresence>
{isOpen && (
<motion.div
ref={menuRef}
role="menu"
className={cn(
"absolute z-50 mt-2 min-w-48 overflow-hidden rounded-lg",
"border border-border bg-background shadow-lg",
"dark:border-border dark:bg-background",
align === "right" ? "right-0" : "left-0",
)}
{...animationProps}
>
{items.map((item, index) => (
<button
key={index}
role="menuitem"
onClick={() => {
item.onClick?.();
setIsOpen(false);
}}
className={cn(
"w-full min-h-11 inline-flex items-center gap-3 px-4 py-3",
"text-sm font-medium transition-colors duration-150",
"text-foreground hover:bg-muted hover:text-muted-foreground",
"focus:outline-none focus:bg-muted focus:text-muted-foreground",
"dark:text-foreground dark:hover:bg-muted dark:focus:bg-muted",
selectedIndex === index && "bg-muted text-muted-foreground",
)}
onMouseEnter={() => setSelectedIndex(index)}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.label}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Loading