diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index e6d9094d..8b1adc85 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
-import { Inter, JetBrains_Mono } from "next/font/google";
+import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google";
import "../globals.css";
import "@ui/globals.css";
import { AuthProvider } from "@lib/auth-context";
@@ -11,6 +11,8 @@ import { Suspense } from "react";
import { Toaster } from "sonner";
import { TourProvider } from "@/components/tour";
import { MobilePanelProvider } from "@/lib/mobile-panel-context";
+import { NuqsAdapter } from 'nuqs/adapters/next/app'
+
import { ViewModeProvider } from "@/lib/view-mode-context";
@@ -24,6 +26,12 @@ const mono = JetBrains_Mono({
variable: "--font-mono",
});
+const serif = Instrument_Serif({
+ subsets: ["latin"],
+ variable: "--font-serif",
+ weight: ["400"],
+});
+
export const metadata: Metadata = {
metadataBase: new URL("https://app.supermemory.ai"),
description: "Your memories, wherever you are",
@@ -38,7 +46,7 @@ export default function RootLayout({
return (
-
- {children}
-
-
+
+
+ {children}
+
+
+
diff --git a/apps/web/app/onboarding/animated-text.tsx b/apps/web/app/onboarding/animated-text.tsx
new file mode 100644
index 00000000..c3616482
--- /dev/null
+++ b/apps/web/app/onboarding/animated-text.tsx
@@ -0,0 +1,53 @@
+'use client';
+import { useEffect } from 'react';
+import { TextEffect } from '@/components/text-effect';
+
+export function AnimatedText({ children, trigger, delay }: { children: string, trigger: boolean, delay: number }) {
+ const blurSlideVariants = {
+ container: {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { staggerChildren: 0.01 },
+ },
+ exit: {
+ transition: { staggerChildren: 0.01, staggerDirection: 1 },
+ },
+ },
+ item: {
+ hidden: {
+ opacity: 0,
+ filter: 'blur(10px) brightness(0%)',
+ y: 0,
+ },
+ visible: {
+ opacity: 1,
+ y: 0,
+ filter: 'blur(0px) brightness(100%)',
+ transition: {
+ duration: 0.4,
+ },
+ },
+ exit: {
+ opacity: 0,
+ y: -30,
+ filter: 'blur(10px) brightness(0%)',
+ transition: {
+ duration: 0.3,
+ },
+ },
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/app/onboarding/bio-form.tsx b/apps/web/app/onboarding/bio-form.tsx
new file mode 100644
index 00000000..984309a9
--- /dev/null
+++ b/apps/web/app/onboarding/bio-form.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { Textarea } from "@ui/components/textarea";
+import { useOnboarding } from "./onboarding-context";
+import { useState } from "react";
+import { Button } from "@ui/components/button";
+import { AnimatePresence, motion } from "motion/react";
+import { NavMenu } from "./nav-menu";
+import { $fetch } from "@lib/api";
+
+export function BioForm() {
+ const [bio, setBio] = useState("");
+ const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
+
+ function handleNext() {
+ const trimmed = bio.trim();
+ if (!trimmed) {
+ nextStep();
+ return;
+ }
+
+ nextStep();
+ void $fetch("@post/memories", {
+ body: {
+ content: trimmed,
+ containerTags: ["sm_project_default"],
+ metadata: { sm_source: "consumer" },
+ },
+ }).catch((error) => {
+ console.error("Failed to save onboarding bio memory:", error);
+ });
+ }
+ return (
+
+
+
+
+ Step {getStepNumberFor("bio")} of {totalSteps}
+
+
+
Tell us about yourself
+
+ What should Supermemory know about you?
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/connections-form.tsx b/apps/web/app/onboarding/connections-form.tsx
new file mode 100644
index 00000000..f71f17ce
--- /dev/null
+++ b/apps/web/app/onboarding/connections-form.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import { motion, type Transition } from "framer-motion";
+import { Button } from "@ui/components/button";
+import { useOnboarding } from "./onboarding-context";
+import { $fetch } from "@lib/api";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import type { ConnectionResponseSchema } from "@repo/validation/api";
+import type { z } from "zod";
+import { Check } from "lucide-react";
+import { toast } from "sonner";
+import { analytics } from "@/lib/analytics";
+import { useProject } from "@/stores";
+import { NavMenu } from "./nav-menu";
+
+type Connection = z.infer;
+
+const CONNECTORS = {
+ "google-drive": {
+ title: "Google Drive",
+ description: "Supermemory can use the documents and files in your Google Drive to better understand and assist you.",
+ iconSrc: "/images/gdrive.svg",
+ },
+ notion: {
+ title: "Notion",
+ description: "Help Supermemory understand how you organize your life and what you have going on by connecting your Notion account.",
+ iconSrc: "/images/notion.svg",
+ },
+ onedrive: {
+ title: "OneDrive",
+ description: "By integrating with OneDrive, Supermemory can better understand both your previous and your current work.",
+ iconSrc: "/images/onedrive.svg",
+ },
+} as const;
+
+type ConnectorProvider = keyof typeof CONNECTORS;
+
+const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { staggerChildren: 0.15, delayChildren: 0.1 } satisfies Transition,
+ },
+};
+
+const itemVariants = {
+ hidden: { opacity: 0, y: 16 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { type: "spring", stiffness: 500, damping: 35, mass: 0.8 } satisfies Transition,
+ },
+};
+
+function ConnectionCard({
+ title,
+ description,
+ iconSrc,
+ isConnected = false,
+ onConnect,
+ isConnecting = false
+}: {
+ title: string;
+ description: string;
+ iconSrc: string;
+ isConnected?: boolean;
+ onConnect?: () => void;
+ isConnecting?: boolean;
+}) {
+ return (
+
+
+
+
+
+
{title}
+
+
{description}
+
+
+ {isConnected ? (
+
+
+ Connected
+
+ ) : (
+
+ Connect
+
+ )}
+
+
+ );
+}
+
+export function ConnectionsForm() {
+ const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
+ const { selectedProject } = useProject();
+
+ const { data: connections = [] } = useQuery({
+ queryKey: ["connections"],
+ queryFn: async () => {
+ const response = await $fetch("@post/connections/list", {
+ body: {
+ containerTags: [],
+ },
+ });
+
+ if (response.error) {
+ throw new Error(
+ response.error?.message || "Failed to load connections",
+ );
+ }
+
+ return response.data as Connection[];
+ },
+ staleTime: 30 * 1000,
+ refetchInterval: 60 * 1000,
+ });
+
+ const addConnectionMutation = useMutation({
+ mutationFn: async (provider: ConnectorProvider) => {
+ const response = await $fetch("@post/connections/:provider", {
+ params: { provider },
+ body: {
+ redirectUrl: window.location.href,
+ containerTags: [selectedProject],
+ },
+ });
+
+ // biome-ignore lint/style/noNonNullAssertion: its fine
+ if ("data" in response && !("error" in response.data!)) {
+ return response.data;
+ }
+
+ throw new Error(response.error?.message || "Failed to connect");
+ },
+ onSuccess: (data, provider) => {
+ analytics.connectionAdded(provider);
+ analytics.connectionAuthStarted();
+ if (data?.authLink) {
+ window.location.href = data.authLink;
+ }
+ },
+ onError: (error, provider) => {
+ analytics.connectionAuthFailed();
+ toast.error(`Failed to connect ${provider}`, {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ },
+ });
+
+ function isConnectorConnected(provider: ConnectorProvider): boolean {
+ return connections.some(connection => connection.provider === provider);
+ }
+
+ function handleConnect(provider: ConnectorProvider) {
+ addConnectionMutation.mutate(provider);
+ }
+
+ return (
+
+
+
+
+ Step {getStepNumberFor("connections")} of {totalSteps}
+
+
+
Connect your accounts
+
+ Help Supermemory get to know you and your documents better
+ {/* The more context you provide, the better Supermemory becomes */}
+ {/* Supermemory understands your needs and goals better with more context */}
+ {/* Supermemory understands you better when it integrates with your apps */}
+
+
+
+ {Object.entries(CONNECTORS).map(([provider, config]) => {
+ const providerKey = provider as ConnectorProvider;
+ const isConnected = isConnectorConnected(providerKey);
+ const isConnecting = addConnectionMutation.isPending && addConnectionMutation.variables === providerKey;
+
+ return (
+
+ handleConnect(providerKey)}
+ isConnecting={isConnecting}
+ />
+
+ );
+ })}
+
+
+
+ Skip For Now
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/extension-form.tsx b/apps/web/app/onboarding/extension-form.tsx
new file mode 100644
index 00000000..8ce40b99
--- /dev/null
+++ b/apps/web/app/onboarding/extension-form.tsx
@@ -0,0 +1,707 @@
+"use client";
+
+import { ArrowUpIcon, MicIcon, PlusIcon, MousePointer2, LoaderIcon, CheckIcon, XIcon, ChevronRightIcon } from "lucide-react";
+import { NavMenu } from "./nav-menu";
+import { useOnboarding } from "./onboarding-context";
+import { motion, AnimatePresence, type ResolvedValues } from "framer-motion";
+import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react";
+import React from "react";
+import { cn } from "@lib/utils";
+import { Button } from "@ui/components/button";
+
+type CursorAction =
+ | { type: 'startAt'; target: React.RefObject }
+ | { type: 'startAtPercent'; xPercent: number; yPercent: number }
+ | { type: 'move'; target: React.RefObject; duration: number }
+ | { type: 'move'; xPercent: number; yPercent: number; duration: number }
+ | { type: 'pause'; duration: number }
+ | { type: 'click' }
+ | { type: 'call'; fn: () => void }
+
+interface CursorProps {
+ actions: CursorAction[];
+ className?: string;
+ onPositionChange?: (clientX: number, clientY: number) => void;
+}
+
+function useContainerRect(ref: React.RefObject) {
+ const rectRef = React.useRef(null);
+
+ useLayoutEffect(function setup() {
+ if (!ref.current) return;
+
+ function measure() {
+ if (ref.current) {
+ rectRef.current = ref.current.getBoundingClientRect();
+ }
+ }
+
+ measure();
+
+ let resizeObserver: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ resizeObserver = new ResizeObserver(function onResize() {
+ measure();
+ });
+ if (ref.current) {
+ resizeObserver.observe(ref.current);
+ }
+ }
+
+ function onScroll() {
+ measure();
+ }
+
+ window.addEventListener("resize", measure);
+ window.addEventListener("scroll", onScroll, true);
+
+ return function cleanup() {
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ window.removeEventListener("resize", measure);
+ window.removeEventListener("scroll", onScroll, true);
+ };
+ }, [ref]);
+
+ return rectRef;
+}
+
+function Cursor({ actions, className, onPositionChange }: CursorProps) {
+ const [position, setPosition] = useState({ x: '0px', y: '0px' });
+ const [scale, setScale] = useState(1);
+ const [currentMoveDuration, setCurrentMoveDuration] = useState(0.7); // Default move duration
+ const containerRef = useRef(null);
+ const timeoutsRef = useRef([]);
+ const containerRectRef = useContainerRect(containerRef);
+ const lastUpdateRef = useRef(0);
+
+ function moveToElement(elementRef: React.RefObject, duration: number) {
+ if (!containerRef.current || !elementRef.current) return;
+
+ setCurrentMoveDuration(duration / 1000); // Convert to seconds for Framer Motion
+
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const elementRect = elementRef.current.getBoundingClientRect();
+ // Position the TOP-LEFT of the cursor at the center of the target element
+ const x = elementRect.left - containerRect.left + elementRect.width / 2;
+ const y = elementRect.top - containerRect.top + elementRect.height / 2;
+
+ setPosition({ x: `${x}px`, y: `${y}px` });
+ }
+
+ function setPositionByPercent(xPercent: number, yPercent: number) {
+ if (!containerRef.current) return;
+ const containerRect = containerRef.current.getBoundingClientRect();
+ // Percentages indicate where the TOP-LEFT of the cursor should be placed
+ const x = (containerRect.width * xPercent) / 100;
+ const y = (containerRect.height * yPercent) / 100;
+ setCurrentMoveDuration(0); // snap without animating
+ setPosition({ x: `${x}px`, y: `${y}px` });
+ }
+
+ function moveToPercent(xPercent: number, yPercent: number, duration: number) {
+ if (!containerRef.current) return;
+ setCurrentMoveDuration(duration / 1000);
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const x = (containerRect.width * xPercent) / 100;
+ const y = (containerRect.height * yPercent) / 100;
+ setPosition({ x: `${x}px`, y: `${y}px` });
+ }
+
+ useEffect(() => {
+ // Clear any existing timeouts before scheduling new ones
+ timeoutsRef.current.forEach((id) => clearTimeout(id));
+ timeoutsRef.current = [];
+
+ let timeAccumulator = 0;
+
+ function schedule(callback: () => void, delay: number): number {
+ const id = window.setTimeout(callback, delay);
+ timeoutsRef.current.push(id);
+ return id;
+ }
+
+ function executeActions(): void {
+ actions.forEach((action) => {
+ // startAt should apply immediately at its place in the sequence and not advance time
+ if (action.type === 'startAt') {
+ moveToElement(action.target, 0);
+ return;
+ }
+ if (action.type === 'startAtPercent') {
+ setPositionByPercent(action.xPercent, action.yPercent);
+ return;
+ }
+
+ schedule(() => {
+ switch (action.type) {
+ case 'move':
+ if ('target' in action) {
+ moveToElement(action.target, action.duration);
+ } else {
+ moveToPercent(action.xPercent, action.yPercent, action.duration);
+ }
+ break;
+ case 'click':
+ setScale(0.9);
+ schedule(function resetClickScale() { setScale(1); }, 100); // Fixed 100ms click duration
+ break;
+ case 'call':
+ try {
+ action.fn();
+ } catch (_) {
+ // no-op on errors to avoid breaking demo
+ }
+ break;
+ case 'pause':
+ // Pause doesn't require any action, just time passing
+ break;
+ }
+ }, timeAccumulator);
+
+ // Add this action's duration to the accumulator for the next action
+ if (action.type === 'click') {
+ timeAccumulator += 100;
+ } else if (action.type === 'pause') {
+ timeAccumulator += action.duration;
+ } else if (action.type === 'move') {
+ timeAccumulator += action.duration;
+ } else {
+ // 'call' and startAt/startAtPercent don't consume time
+ }
+ });
+ }
+
+ // make sure refs are ready
+ schedule(executeActions, 100);
+
+ return function cleanup(): void {
+ timeoutsRef.current.forEach((id) => clearTimeout(id));
+ timeoutsRef.current = [];
+ };
+ }, [actions]);
+
+ return (
+
+ {
+ if (!onPositionChange) return;
+ const containerRect = containerRectRef.current;
+ if (!containerRect) return;
+ const now = performance.now();
+ if (now - lastUpdateRef.current < 50) return; // ~20fps throttle
+ lastUpdateRef.current = now;
+ const latestX = typeof latest.x === 'number' ? latest.x : parseFloat(String(latest.x || 0));
+ const latestY = typeof latest.y === 'number' ? latest.y : parseFloat(String(latest.y || 0));
+ const clientX = containerRect.left + latestX;
+ const clientY = containerRect.top + latestY;
+ onPositionChange(clientX, clientY);
+ }}
+ >
+
+
+
+ )
+}
+
+function SnippetDemo() {
+ const snippetRootRef = useRef(null);
+ const sentenceRef = useRef(null);
+ const [currentEndIndex, setCurrentEndIndex] = useState(0);
+ const lastStableIndexRef = useRef(0);
+ const [cursorActions, setCursorActions] = useState([]);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [hoveredMenuIndex, setHoveredMenuIndex] = useState(null);
+ const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const menuItem6Ref = useRef(null);
+ const charRectsRef = useRef([]);
+
+ const targetText = "There's an Italian dish called saltimbocca, which means \"leap into the mouth.\"";
+
+ function getIndexFromClientPoint(clientX: number, clientY: number): number {
+ const rects = charRectsRef.current;
+ if (!sentenceRef.current || rects.length === 0) return 0;
+ let bestIdx = 0;
+ let bestDist = Number.POSITIVE_INFINITY;
+ for (let i = 0; i < rects.length; i++) {
+ const r = rects[i];
+ if (!r) continue;
+ const cx = r.left + r.width / 2;
+ const cy = r.top + r.height / 2;
+ const dx = clientX - cx;
+ const dy = clientY - cy;
+ const d = dx * dx + dy * dy;
+ if (d < bestDist) {
+ bestDist = d;
+ bestIdx = i;
+ }
+ }
+ return bestIdx;
+ }
+
+ function getMenuItemIndexFromPoint(clientX: number, clientY: number): number | null {
+ const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
+ if (!el) return null;
+
+ const menuItem = el.closest('[data-menu-idx]') as HTMLElement | null;
+ if (menuItem) {
+ const idx = parseInt(menuItem.dataset.menuIdx || '', 10);
+ return Number.isFinite(idx) ? idx : null;
+ }
+ return null;
+ }
+
+ useLayoutEffect(function setupCharRectsMeasurement() {
+ function measureCharRects(): void {
+ if (!sentenceRef.current) {
+ charRectsRef.current = [];
+ return;
+ }
+ const spans = sentenceRef.current.querySelectorAll('span[data-idx]');
+ const rects: DOMRect[] = [];
+ spans.forEach(function collect(node) {
+ rects.push((node as HTMLElement).getBoundingClientRect());
+ });
+ charRectsRef.current = rects;
+ }
+
+ measureCharRects();
+
+ let ro1: ResizeObserver | null = null;
+ let ro2: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro1 = new ResizeObserver(function onResize() { measureCharRects(); });
+ ro2 = new ResizeObserver(function onResize() { measureCharRects(); });
+ if (snippetRootRef.current) ro1.observe(snippetRootRef.current);
+ if (sentenceRef.current) ro2.observe(sentenceRef.current);
+ }
+
+ function onScroll(): void { measureCharRects(); }
+ window.addEventListener("resize", measureCharRects);
+ window.addEventListener("scroll", onScroll, true);
+
+ return function cleanup(): void {
+ if (ro1) ro1.disconnect();
+ if (ro2) ro2.disconnect();
+ window.removeEventListener("resize", measureCharRects);
+ window.removeEventListener("scroll", onScroll, true);
+ };
+ }, [targetText]);
+
+ useEffect(function setupActionsOnce() {
+ lastStableIndexRef.current = 0;
+ setCurrentEndIndex(0);
+ if (!sentenceRef.current) return;
+ const total = targetText.length;
+ const firstSpan = sentenceRef.current.querySelector('span[data-idx="0"]') as HTMLSpanElement | null;
+ const lastSpan = sentenceRef.current.querySelector(`span[data-idx="${Math.max(0, total - 1)}"]`) as HTMLSpanElement | null;
+ if (!firstSpan || !lastSpan) return;
+ const firstRef = { current: firstSpan } as React.RefObject;
+ const lastRef = { current: lastSpan } as React.RefObject;
+ setCursorActions([
+ { type: 'call', fn: function reset() { lastStableIndexRef.current = 0; setCurrentEndIndex(0); setHoveredMenuIndex(null); } },
+ { type: 'startAt', target: firstRef },
+ { type: 'pause', duration: 200 },
+ { type: 'move', target: lastRef, duration: 1800 },
+ { type: 'pause', duration: 1200 },
+ { type: "click" },
+ { type: 'call', fn: () => { setMenuOpen(true); } },
+ { type: 'pause', duration: 1000 },
+ { type: 'move', target: menuItem6Ref, duration: 1000 },
+ { type: 'pause', duration: 500 },
+ { type: 'click' },
+ ]);
+ }, []);
+
+ return (
+
+
+
+ writing is easier to read, and the easier something is to read, the more deeply readers will engage with it. The less energy they expend on your prose, the more they'll have left for your ideas. And the further they'll read. Most readers' energy tends to flag part way through an article or essay. If the friction of reading is low enough, more keep going till the end.
+ {" "}
+
+ {targetText.split("").map(function renderChar(ch, idx) {
+ const highlighted = idx <= currentEndIndex;
+ return (
+
+ {ch}
+
+ );
+ })}
+
+
+ {menuOpen && (
+
+ {["Back", "Forward", "Reload", "Save As...", "Print...", "Translate to English", "Save to Supermemory", "View Page Source", "Inspect"].map((item, idx) => (
+
+ { menuItemRefs.current[idx] = el; if (idx === 6) { menuItem6Ref.current = el; } }}
+ data-menu-idx={idx}
+ className={cn(
+ "px-2 py-0.5 flex items-center gap-1.5 rounded-sm transition-colors",
+ (idx === 0 || idx === 1) && "text-white/30",
+ hoveredMenuIndex === idx && "bg-blue-500"
+ )}
+ >
+ {idx === 6 &&
}
+ {item}
+
+ {[2, 5, 6].includes(idx) &&
}
+
+ ))}
+
+ )}
+
+ {" "}My goal when writing might be called saltintesta: the ideas leap into your head and you barely notice the words that got them there. It's too much to hope that writing could ever be pure ideas. You might not even want it to be. But for most writers, most of the time, that's the goal to aim for. The gap between most writing and pure ideas is not filled with poetry. Plus it's more considerate to write simply. When you write in a fancy way to impress people, you're making them do extra work just so you can seem cool. It's like trailing a long train behind you that readers have to carry.
+
+
+ )
+}
+
+function ChatGPTDemo() {
+ const iconRef = useRef(null);
+ const submitButtonRef = useRef(null);
+ const [enhancementStatus, setEnhancementStatus] = useState<"notStarted" | "enhancing" | "done">("notStarted");
+ const [memoriesExpanded, setMemoriesExpanded] = useState(false);
+
+ const cursorActions: CursorAction[] = useMemo(() => [
+ {
+ type: "call", fn: function resetStates() {
+ setEnhancementStatus("notStarted");
+ setMemoriesExpanded(false);
+ }
+ },
+ { type: 'startAtPercent', xPercent: 80, yPercent: 80 },
+ { type: 'pause', duration: 1000 },
+ { type: 'move', target: iconRef, duration: 1000 },
+ { type: 'pause', duration: 1000 },
+ { type: 'click' },
+ { type: 'call', fn: function startEnhancing() { setEnhancementStatus("enhancing"); } },
+ { type: 'pause', duration: 1000 },
+ { type: 'move', xPercent: 10, yPercent: 80, duration: 1000 },
+ { type: 'call', fn: function finishEnhancing() { setEnhancementStatus("done"); } },
+ { type: 'pause', duration: 500 },
+ { type: 'move', target: iconRef, duration: 1000 },
+ { type: 'call', fn: function expandMemories() { setMemoriesExpanded(true); } },
+ { type: "pause", duration: 1000 },
+ { type: 'move', xPercent: 80, yPercent: 80, duration: 1000 },
+ ], []);
+
+ return (
+
+
+
What's on your mind today?
+
+
+ what are my card's benefits?
+
+
+
+
+
+
+ {enhancementStatus === "notStarted" && (
+
+ )}
+ {enhancementStatus === "enhancing" && (
+ <>
+
+
+ Searching...
+
+ >
+ )}
+ {enhancementStatus === "done" && (
+ <>
+
+ Including 1 memory
+ >
+ )}
+
+ {memoriesExpanded && (
+
+
+
+
User possesses an American Express Platinum card
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function TwitterDemo() {
+ const importButtonRef = useRef(null);
+ const [importStatus, setImportStatus] = useState<"notStarted" | "importing" | "done">("notStarted");
+
+ const cursorActions: CursorAction[] = useMemo(() => [
+ {
+ type: "call", fn: function resetStates() {
+ setImportStatus("notStarted");
+ }
+ },
+ { type: 'startAtPercent', xPercent: 10, yPercent: 80 },
+ { type: 'pause', duration: 1300 },
+ { type: 'move', target: importButtonRef, duration: 1100 },
+ { type: 'pause', duration: 800 },
+ { type: 'click' },
+ { type: 'call', fn: function startImporting() { setImportStatus("importing"); } },
+ { type: 'pause', duration: 700 },
+ { type: 'move', xPercent: 80, yPercent: 80, duration: 1200 },
+ { type: 'call', fn: function finishImporting() { setImportStatus("done"); } },
+ ], []);
+
+ return (
+
+
+
+
+ 𝕏
+ Import Twitter Bookmarks
+
+
+
+
+
+ This will import all your Twitter bookmarks to Supermemory
+
+
+
+
+
+ {importStatus === "importing" && (
+
+
+
+ )}
+ {importStatus === "done" && (
+
+
+
+ )}
+
+
+ {importStatus === "importing"
+ ? "Importing bookmarks..."
+ : importStatus === "done"
+ ? "Import successful"
+ : "Import All Bookmarks"}
+
+
+
+
+
+
+ )
+}
+
+export function ExtensionForm() {
+ const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
+ return (
+
+
+
+
+
+ Step {getStepNumberFor("extension")} of {totalSteps}
+
+
+
Install the Chrome extension
+
+ {/* Install the Supermemory extension to start saving and organizing everything that matters. */}
+ Bring Supermemory everywhere
+
+
+
+
+
+
+
+
+
Remember anything, anywhere
+
+ Just right-click to save instantly.
+
+
+
+
+
+
+
+
+
+
+
Integrate with your AI
+ {/* Supercharge your AI with memory */}
+ {/* Supercharge any AI with Supermemory. */}
+ {/* ChatGPT is better with Supermemory. */}
+ {/* Seamless integration with your workflow */}
+
+ {/* Integrates with ChatGPT and Claude. */}
+ {/* Integrates with your chat apps */}
+ Enhance any prompt with Supermemory.
+ {/* Seamlessly */}
+
+
+
+
+
+
+
+
+
+
Import Twitter bookmarks
+
+ Search semantically and effortlessly.
+ {/* Import instantly and search effortlessly. */}
+
+
+
+
+
+
+
+
+
+ Continue
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/floating-orbs.tsx b/apps/web/app/onboarding/floating-orbs.tsx
new file mode 100644
index 00000000..96a8f061
--- /dev/null
+++ b/apps/web/app/onboarding/floating-orbs.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import { motion, useReducedMotion } from "motion/react";
+import { useEffect, useMemo, useState, memo } from "react";
+import { useOnboarding } from "./onboarding-context";
+
+interface OrbProps {
+ size: number;
+ initialX: number;
+ initialY: number;
+ duration: number;
+ delay: number;
+ revealDelay: number;
+ shouldReveal: boolean;
+ color: {
+ primary: string;
+ secondary: string;
+ tertiary: string;
+ };
+}
+
+function FloatingOrb({ size, initialX, initialY, duration, delay, revealDelay, shouldReveal, color }: OrbProps) {
+ const blurPixels = Math.min(64, Math.max(24, Math.floor(size * 0.08)));
+
+ const gradient = useMemo(() => {
+ return `radial-gradient(circle, ${color.primary} 0%, ${color.secondary} 40%, ${color.tertiary} 70%, transparent 100%)`;
+ }, [color.primary, color.secondary, color.tertiary]);
+
+ const style = useMemo(() => {
+ return {
+ width: size,
+ height: size,
+ background: gradient,
+ filter: `blur(${blurPixels}px)`,
+ willChange: "transform, opacity",
+ mixBlendMode: "plus-lighter",
+ } as any;
+ }, [size, gradient, blurPixels]);
+
+ const initial = useMemo(() => {
+ return {
+ x: initialX,
+ y: initialY,
+ scale: 0,
+ opacity: 0,
+ };
+ }, [initialX, initialY]);
+
+ const animate = useMemo(() => {
+ if (!shouldReveal) {
+ return {
+ x: initialX,
+ y: initialY,
+ scale: 0,
+ opacity: 0,
+ };
+ }
+ return {
+ x: [initialX, initialX + 200, initialX - 150, initialX + 100, initialX],
+ y: [initialY, initialY - 180, initialY + 120, initialY - 80, initialY],
+ scale: [0.8, 1.2, 0.9, 1.1, 0.8],
+ opacity: 0.7,
+ };
+ }, [shouldReveal, initialX, initialY]);
+
+ const transition = useMemo(() => {
+ return {
+ x: {
+ duration: shouldReveal ? duration : 0,
+ repeat: shouldReveal ? Infinity : 0,
+ ease: [0.42, 0, 0.58, 1],
+ delay: shouldReveal ? delay + revealDelay : 0,
+ },
+ y: {
+ duration: shouldReveal ? duration : 0,
+ repeat: shouldReveal ? Infinity : 0,
+ ease: [0.42, 0, 0.58, 1],
+ delay: shouldReveal ? delay + revealDelay : 0,
+ },
+ scale: {
+ duration: shouldReveal ? duration : 0.8,
+ repeat: shouldReveal ? Infinity : 0,
+ ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
+ delay: shouldReveal ? delay + revealDelay : revealDelay,
+ },
+ opacity: {
+ duration: 1.2,
+ ease: [0, 0, 0.58, 1],
+ delay: shouldReveal ? revealDelay : 0,
+ },
+ } as any;
+ }, [shouldReveal, duration, delay, revealDelay]);
+
+ return (
+
+ );
+}
+
+const MemoFloatingOrb = memo(FloatingOrb);
+
+export function FloatingOrbs() {
+ const { orbsRevealed } = useOnboarding();
+ const reduceMotion = useReducedMotion();
+ const [mounted, setMounted] = useState(false);
+ const [orbs, setOrbs] = useState>([]);
+
+ useEffect(() => {
+ setMounted(true);
+
+ const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1200;
+ const screenHeight = typeof window !== "undefined" ? window.innerHeight : 800;
+
+ // Define edge zones (avoiding center)
+ const edgeThickness = Math.min(screenWidth, screenHeight) * 0.25; // 25% of smaller dimension
+
+ // Define rainbow color palette
+ const colorPalette = [
+ { // Magenta
+ primary: "rgba(255, 0, 150, 0.6)",
+ secondary: "rgba(255, 100, 200, 0.4)",
+ tertiary: "rgba(255, 150, 220, 0.1)"
+ },
+ { // Yellow
+ primary: "rgba(255, 235, 59, 0.6)",
+ secondary: "rgba(255, 245, 120, 0.4)",
+ tertiary: "rgba(255, 250, 180, 0.1)"
+ },
+ { // Light Blue
+ primary: "rgba(100, 181, 246, 0.6)",
+ secondary: "rgba(144, 202, 249, 0.4)",
+ tertiary: "rgba(187, 222, 251, 0.1)"
+ },
+ { // Orange (keeping original)
+ primary: "rgba(255, 154, 0, 0.6)",
+ secondary: "rgba(255, 206, 84, 0.4)",
+ tertiary: "rgba(255, 154, 0, 0.1)"
+ },
+ { // Very Light Red/Pink
+ primary: "rgba(255, 138, 128, 0.6)",
+ secondary: "rgba(255, 171, 145, 0.4)",
+ tertiary: "rgba(255, 205, 210, 0.1)"
+ }
+ ];
+
+ // Generate orb configurations positioned along edges
+ const newOrbs = Array.from({ length: 8 }, (_, i) => {
+ let x, y;
+ const zone = i % 4; // Rotate through 4 zones: top, right, bottom, left
+
+ switch (zone) {
+ case 0: // Top edge
+ x = Math.random() * screenWidth;
+ y = Math.random() * edgeThickness;
+ break;
+ case 1: // Right edge
+ x = screenWidth - edgeThickness + Math.random() * edgeThickness;
+ y = Math.random() * screenHeight;
+ break;
+ case 2: // Bottom edge
+ x = Math.random() * screenWidth;
+ y = screenHeight - edgeThickness + Math.random() * edgeThickness;
+ break;
+ case 3: // Left edge
+ x = Math.random() * edgeThickness;
+ y = Math.random() * screenHeight;
+ break;
+ default:
+ x = Math.random() * screenWidth;
+ y = Math.random() * screenHeight;
+ }
+
+ return {
+ id: i,
+ size: Math.random() * 300 + 200, // 200px to 500px
+ initialX: x,
+ initialY: y,
+ duration: Math.random() * 20 + 15, // 15-35 seconds (longer for more gentle movement)
+ delay: i * 0.4, // Staggered start for floating animation
+ revealDelay: i * 0.2, // Faster staggered reveal
+ color: colorPalette[i % colorPalette.length]!, // Cycle through rainbow colors
+ };
+ });
+
+ setOrbs(newOrbs);
+ }, []);
+
+ if (!mounted || orbs.length === 0) return null;
+
+ return (
+
+ {orbs.map((orb) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/onboarding/intro.tsx b/apps/web/app/onboarding/intro.tsx
new file mode 100644
index 00000000..ce106b59
--- /dev/null
+++ b/apps/web/app/onboarding/intro.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { AnimatedText } from "./animated-text";
+import { motion, AnimatePresence } from "motion/react";
+import { Button } from "@repo/ui/components/button";
+import { cn } from "@lib/utils";
+import { ArrowRightIcon } from "lucide-react";
+import { useOnboarding } from "./onboarding-context";
+import { useIsMobile } from "@hooks/use-mobile";
+
+export function Intro() {
+ const { nextStep, introTriggers: triggers } = useOnboarding();
+ const isMobile = useIsMobile();
+
+ return (
+
+
+ {triggers.first && (
+
+ {isMobile ? (
+
+
+ Still looking for your
+
+
+ other half?
+
+
+ ) : (
+
+ Still looking for your other half?
+
+ )}
+
+ )}
+ {triggers.second && (
+
+
+ Don't worry.
+
+
+ )}
+ {triggers.third && (
+
+
+ {/* You just found it. */}
+ {/* You're about to find it. */}
+ It's right here.
+ {/* It's right in front of you. */}
+ {/* You're looking at it. */}
+
+
+ )}
+
+
+
+ Meet Supermemory
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/mcp-form.tsx b/apps/web/app/onboarding/mcp-form.tsx
new file mode 100644
index 00000000..50d48979
--- /dev/null
+++ b/apps/web/app/onboarding/mcp-form.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@ui/components/select";
+import { useOnboarding } from "./onboarding-context";
+import { useEffect, useState } from "react";
+import { Button } from "@ui/components/button";
+import { CheckIcon, CircleCheckIcon, CopyIcon, LoaderIcon } from "lucide-react";
+import { toast } from "sonner";
+import { TextMorph } from "@/components/text-morph";
+import { NavMenu } from "./nav-menu";
+import { cn } from "@lib/utils";
+import { motion, AnimatePresence } from "framer-motion";
+import { useQuery } from "@tanstack/react-query";
+import { $fetch } from "@lib/api";
+
+const clients = {
+ cursor: "Cursor",
+ claude: "Claude Desktop",
+ vscode: "VSCode",
+ cline: "Cline",
+ "roo-cline": "Roo Cline",
+ witsy: "Witsy",
+ enconvo: "Enconvo",
+ "gemini-cli": "Gemini CLI",
+ "claude-code": "Claude Code",
+} as const;
+
+export function MCPForm() {
+ const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
+ const [client, setClient] = useState("cursor");
+ const [isCopied, setIsCopied] = useState(false);
+ const [isInstalling, setIsInstalling] = useState(true);
+
+ const hasLoginQuery = useQuery({
+ queryKey: ["mcp", "has-login"],
+ queryFn: async (): Promise<{ previousLogin: boolean }> => {
+ const response = await $fetch("@get/mcp/has-login");
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to check MCP login");
+ }
+ return response.data as { previousLogin: boolean };
+ },
+ enabled: isInstalling,
+ refetchInterval: isInstalling ? 1000 : false,
+ staleTime: 0,
+ });
+
+ useEffect(() => {
+ if (hasLoginQuery.data?.previousLogin) {
+ setIsInstalling(false);
+ }
+ }, [hasLoginQuery.data?.previousLogin]);
+
+ return (
+
+
+
+
+ Step {getStepNumberFor("mcp")} of {totalSteps}
+
+
+
Install the MCP server
+
+ Bring Supermemory to all your favourite tools
+
+
+
+
+
+
+
Select the app you want to install Supermemory MCP to
+
setClient(value as keyof typeof clients)}
+ value={client}
+ >
+
+
+
+
+ {Object.entries(clients).map(([key, value]) => (
+
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
Copy the installation command
+
+
+ npx -y install-mcp@latest https://api.supermemory.ai/mcp --client {client} --oauth=yes
+
+
{
+ navigator.clipboard.writeText(`npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`);
+ setIsCopied(true);
+ setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ }}>
+ {isCopied ? : }
+
+ {isCopied ? "Copied!" : "Copy"}
+
+
+
+
+
+
+
+
+
Run the command in your terminal of choice
+
+
+ {isInstalling ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {isInstalling ? "Waiting for installation..." : "Installation complete!"}
+
+
+
+
+
+
+ {
+ !isInstalling ? (
+
+
+
+ Continue
+
+
+ ) :
+
+
+ Skip For Now
+
+
+ }
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/name-form.tsx b/apps/web/app/onboarding/name-form.tsx
new file mode 100644
index 00000000..c112933c
--- /dev/null
+++ b/apps/web/app/onboarding/name-form.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { Button } from "@repo/ui/components/button";
+import { useOnboarding } from "./onboarding-context";
+import { useAuth } from "@lib/auth-context";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { Input } from "@ui/components/input";
+import { CheckIcon } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { NavMenu } from "./nav-menu";
+import { authClient } from "@lib/auth";
+
+export function NameForm() {
+ const { nextStep, totalSteps, getStepNumberFor } = useOnboarding();
+ const { user } = useAuth();
+ const [name, setName] = useState(user?.name ?? "");
+
+ useEffect(() => {
+ if (!name && user?.name) {
+ setName(user.name);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.name]);
+
+ function handleNext(): void {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ nextStep();
+ return;
+ }
+
+ nextStep();
+ void authClient
+ .updateUser({ name: trimmed })
+ .catch((error: unknown) => {
+ console.error("Failed to update user name during onboarding:", error);
+ });
+ }
+
+ function handleSubmit(e: React.FormEvent): void {
+ e.preventDefault();
+ handleNext();
+ }
+
+ if (!user) {
+ return (
+
+
You need to sign in to continue
+ Login
+
+ );
+ }
+
+ return (
+
+
+
+ Step {getStepNumberFor("name")} of {totalSteps}
+
+
+
+ What should we call you?
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/nav-menu.tsx b/apps/web/app/onboarding/nav-menu.tsx
new file mode 100644
index 00000000..e66187d2
--- /dev/null
+++ b/apps/web/app/onboarding/nav-menu.tsx
@@ -0,0 +1,44 @@
+"use client";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@ui/components/hover-card"
+import { useOnboarding, type OnboardingStep } from "./onboarding-context";
+import { useState } from "react";
+import { cn } from "@lib/utils";
+
+export function NavMenu({ children }: { children: React.ReactNode }) {
+ const { setStep, currentStep, visibleSteps, getStepNumberFor } = useOnboarding();
+ const [open, setOpen] = useState(false);
+ const LABELS: Record = {
+ intro: "Intro",
+ name: "Name",
+ bio: "About you",
+ connections: "Connections",
+ mcp: "MCP",
+ extension: "Extension",
+ welcome: "Welcome",
+ };
+ const navigableSteps = visibleSteps.filter(step => step !== "intro" && step !== "welcome");
+ return (
+
+ {children}
+
+ Go to step
+
+ {navigableSteps.map((step) => (
+
+ {
+ setStep(step);
+ setOpen(false);
+ }}>
+ {getStepNumberFor(step)}. {LABELS[step]}
+
+
+ ))}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/onboarding-context.tsx b/apps/web/app/onboarding/onboarding-context.tsx
new file mode 100644
index 00000000..13e13139
--- /dev/null
+++ b/apps/web/app/onboarding/onboarding-context.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect, type ReactNode, useMemo } from "react";
+import { useQueryState } from "nuqs";
+import { useIsMobile } from "@hooks/use-mobile";
+
+// Define the context interface
+interface OnboardingContextType {
+ currentStep: OnboardingStep;
+ setStep: (step: OnboardingStep) => void;
+ nextStep: () => void;
+ previousStep: () => void;
+ totalSteps: number;
+ currentStepIndex: number;
+ // Visible-step aware helpers
+ visibleSteps: OnboardingStep[];
+ currentVisibleStepIndex: number;
+ currentVisibleStepNumber: number;
+ getStepNumberFor: (step: OnboardingStep) => number;
+ introTriggers: {
+ first: boolean;
+ second: boolean;
+ third: boolean;
+ fourth: boolean;
+ };
+ orbsRevealed: boolean;
+ resetIntroTriggers: () => void;
+}
+
+// Create the context
+const OnboardingContext = createContext(undefined);
+
+// Define the base step order
+const BASE_STEP_ORDER = ["intro", "name", "bio", "connections", "mcp", "extension", "welcome"] as const;
+
+export type OnboardingStep = (typeof BASE_STEP_ORDER)[number];
+
+interface OnboardingProviderProps {
+ children: ReactNode;
+ initialStep?: OnboardingStep;
+}
+
+export function OnboardingProvider({ children, initialStep = "intro" }: OnboardingProviderProps) {
+ // Helper function to validate if a step is valid
+ const isValidStep = (step: string): step is OnboardingStep => {
+ return BASE_STEP_ORDER.includes(step as OnboardingStep);
+ };
+
+ const [currentStep, setCurrentStep] = useQueryState("step", {
+ defaultValue: initialStep,
+ parse: (value: string) => {
+ // Validate the step from URL - if invalid, use the initial step
+ return isValidStep(value) ? value : initialStep;
+ },
+ serialize: (value: OnboardingStep) => value,
+ });
+ const [orbsRevealed, setOrbsRevealed] = useState(false);
+ const [introTriggers, setIntroTriggers] = useState({
+ first: false,
+ second: false,
+ third: false,
+ fourth: false
+ });
+ const isMobile = useIsMobile();
+
+ // Compute visible steps based on device
+ const visibleSteps = useMemo(() => {
+ if (isMobile) {
+ // On mobile, hide MCP and Extension steps
+ return BASE_STEP_ORDER.filter(s => s !== "mcp" && s !== "extension");
+ }
+ return [...BASE_STEP_ORDER];
+ }, [isMobile]);
+
+ // Setup intro trigger timings when on intro step
+ useEffect(() => {
+ if (currentStep !== "intro") return;
+
+ const cleanups = [
+ setTimeout(() => {
+ setIntroTriggers(prev => ({ ...prev, first: true }));
+ }, 300),
+ setTimeout(() => {
+ setIntroTriggers(prev => ({ ...prev, second: true }));
+ }, 2000),
+ setTimeout(() => {
+ setIntroTriggers(prev => ({ ...prev, third: true }));
+ }, 4000),
+ setTimeout(() => {
+ setIntroTriggers(prev => ({ ...prev, fourth: true }));
+ }, 5500),
+ ];
+
+ return () => cleanups.forEach(cleanup => clearTimeout(cleanup));
+ }, [currentStep]);
+
+ // Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step
+ useEffect(() => {
+ if (currentStep !== "intro") {
+ // If we're not on the intro step, orbs should always be visible
+ // (user has either completed intro or navigated directly to another step)
+ if (!orbsRevealed) {
+ setOrbsRevealed(true);
+ }
+ } else if (introTriggers.fourth && !orbsRevealed) {
+ // On intro step, reveal orbs only after the fourth trigger
+ setOrbsRevealed(true);
+ }
+ }, [introTriggers.fourth, orbsRevealed, currentStep]);
+
+ // Ensure current step is always part of visible steps; if not, advance to the next visible step
+ useEffect(() => {
+ if (!visibleSteps.includes(currentStep)) {
+ if (visibleSteps.length === 0) return;
+ const baseIndex = BASE_STEP_ORDER.indexOf(currentStep);
+ // Find the next visible step after the current base index
+ const nextAfterBase = visibleSteps.find(step => BASE_STEP_ORDER.indexOf(step) > baseIndex);
+ const targetStep = nextAfterBase ?? visibleSteps[visibleSteps.length - 1]!;
+ setCurrentStep(targetStep);
+ }
+ }, [visibleSteps, currentStep]);
+
+ function setStep(step: OnboardingStep) {
+ setCurrentStep(step);
+ }
+
+ function nextStep() {
+ const currentIndex = visibleSteps.indexOf(currentStep);
+ const nextIndex = currentIndex + 1;
+
+ if (nextIndex < visibleSteps.length) {
+ setStep(visibleSteps[nextIndex]!);
+ }
+ }
+
+ function previousStep() {
+ const currentIndex = visibleSteps.indexOf(currentStep);
+ const previousIndex = currentIndex - 1;
+
+ if (previousIndex >= 0) {
+ setStep(visibleSteps[previousIndex]!);
+ }
+ }
+
+ function resetIntroTriggers() {
+ setIntroTriggers({
+ first: false,
+ second: false,
+ third: false,
+ fourth: false
+ });
+ }
+
+ const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep);
+
+ // Visible-step aware helpers
+ const stepsForNumbering = useMemo(() => visibleSteps.filter(s => s !== "intro" && s !== "welcome"), [visibleSteps]);
+
+ function getStepNumberFor(step: OnboardingStep): number {
+ if (step === "intro" || step === "welcome") {
+ return 0;
+ }
+ const idx = stepsForNumbering.indexOf(step);
+ return idx === -1 ? 0 : idx + 1;
+ }
+
+ const currentVisibleStepIndex = useMemo(() => visibleSteps.indexOf(currentStep), [visibleSteps, currentStep]);
+ const currentVisibleStepNumber = useMemo(() => getStepNumberFor(currentStep), [currentStep, stepsForNumbering]);
+ const totalSteps = stepsForNumbering.length;
+
+ const contextValue: OnboardingContextType = {
+ currentStep,
+ setStep,
+ nextStep,
+ previousStep,
+ totalSteps,
+ currentStepIndex,
+ visibleSteps,
+ currentVisibleStepIndex,
+ currentVisibleStepNumber,
+ getStepNumberFor,
+ introTriggers,
+ orbsRevealed,
+ resetIntroTriggers,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useOnboarding() {
+ const context = useContext(OnboardingContext);
+
+ if (context === undefined) {
+ throw new Error("useOnboarding must be used within an OnboardingProvider");
+ }
+
+ return context;
+}
diff --git a/apps/web/app/onboarding/onboarding-form.tsx b/apps/web/app/onboarding/onboarding-form.tsx
new file mode 100644
index 00000000..962f8e47
--- /dev/null
+++ b/apps/web/app/onboarding/onboarding-form.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { motion, AnimatePresence } from "motion/react";
+import { NameForm } from "./name-form";
+import { Intro } from "./intro";
+import { useOnboarding } from "./onboarding-context";
+import { BioForm } from "./bio-form";
+import { ConnectionsForm } from "./connections-form";
+import { ExtensionForm } from "./extension-form";
+import { MCPForm } from "./mcp-form";
+import { Welcome } from "./welcome";
+
+export function OnboardingForm() {
+ const { currentStep, resetIntroTriggers } = useOnboarding();
+
+ return (
+
+
+ {currentStep === "intro" && (
+
+
+
+ )}
+ {currentStep === "name" && (
+
+
+
+ )}
+ {currentStep === "bio" && (
+
+
+
+ )}
+ {currentStep === "connections" && (
+
+
+
+ )}
+ {currentStep === "mcp" && (
+
+
+
+ )}
+ {currentStep === "extension" && (
+
+
+
+ )}
+ {currentStep === "welcome" && (
+
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx
new file mode 100644
index 00000000..b04f1349
--- /dev/null
+++ b/apps/web/app/onboarding/page.tsx
@@ -0,0 +1,28 @@
+import { getSession } from "@lib/auth";
+import { OnboardingForm } from "./onboarding-form";
+import { OnboardingProvider } from "./onboarding-context";
+import { FloatingOrbs } from "./floating-orbs";
+import { OnboardingProgressBar } from "./progress-bar";
+import { redirect } from "next/navigation";
+import { type Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Welcome to Supermemory",
+ description: "We're excited to have you on board.",
+};
+
+export default function OnboardingPage() {
+ const session = getSession();
+
+ if (!session) redirect("/login");
+
+ return (
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/app/onboarding/progress-bar.tsx b/apps/web/app/onboarding/progress-bar.tsx
new file mode 100644
index 00000000..9bc01b01
--- /dev/null
+++ b/apps/web/app/onboarding/progress-bar.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { motion } from "motion/react";
+import { useOnboarding } from "./onboarding-context";
+
+export function OnboardingProgressBar() {
+ const { currentVisibleStepNumber, totalSteps } = useOnboarding();
+
+ const progress = totalSteps === 0
+ ? 0
+ : (currentVisibleStepNumber / totalSteps) * 100;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/onboarding/welcome.tsx b/apps/web/app/onboarding/welcome.tsx
new file mode 100644
index 00000000..d3b19dcd
--- /dev/null
+++ b/apps/web/app/onboarding/welcome.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { Button } from "@ui/components/button";
+import { ArrowRightIcon, ChevronRightIcon } from "lucide-react";
+
+export function Welcome() {
+ return (
+
+
Welcome to Supermemory
+
+ We're excited to have you on board.
+
+
+
+ Get started
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/components/text-effect.tsx b/apps/web/components/text-effect.tsx
new file mode 100644
index 00000000..82c6823c
--- /dev/null
+++ b/apps/web/components/text-effect.tsx
@@ -0,0 +1,294 @@
+'use client';
+import { cn } from '@lib/utils';
+import {
+ AnimatePresence,
+ motion
+} from 'motion/react';
+import type {
+ TargetAndTransition,
+ Transition,
+ Variant,
+ Variants,
+} from 'motion/react'
+import React from 'react';
+
+export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide';
+
+export type PerType = 'word' | 'char' | 'line';
+
+export type TextEffectProps = {
+ children: string;
+ per?: PerType;
+ as?: keyof React.JSX.IntrinsicElements;
+ variants?: {
+ container?: Variants;
+ item?: Variants;
+ };
+ className?: string;
+ preset?: PresetType;
+ delay?: number;
+ speedReveal?: number;
+ speedSegment?: number;
+ trigger?: boolean;
+ onAnimationComplete?: () => void;
+ onAnimationStart?: () => void;
+ segmentWrapperClassName?: string;
+ containerTransition?: Transition;
+ segmentTransition?: Transition;
+ style?: React.CSSProperties;
+};
+
+const defaultStaggerTimes: Record = {
+ char: 0.03,
+ word: 0.05,
+ line: 0.1,
+};
+
+const defaultContainerVariants: Variants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ },
+ },
+ exit: {
+ transition: { staggerChildren: 0.05, staggerDirection: -1 },
+ },
+};
+
+const defaultItemVariants: Variants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ },
+ exit: { opacity: 0 },
+};
+
+const presetVariants: Record<
+ PresetType,
+ { container: Variants; item: Variants }
+> = {
+ blur: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, filter: 'blur(12px)' },
+ visible: { opacity: 1, filter: 'blur(0px)' },
+ exit: { opacity: 0, filter: 'blur(12px)' },
+ },
+ },
+ 'fade-in-blur': {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, y: 20, filter: 'blur(12px)' },
+ visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
+ exit: { opacity: 0, y: 20, filter: 'blur(12px)' },
+ },
+ },
+ scale: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, scale: 0 },
+ visible: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0 },
+ },
+ },
+ fade: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1 },
+ exit: { opacity: 0 },
+ },
+ },
+ slide: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: 20 },
+ },
+ },
+};
+
+const AnimationComponent: React.FC<{
+ segment: string;
+ variants: Variants;
+ per: 'line' | 'word' | 'char';
+ segmentWrapperClassName?: string;
+}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
+ const content =
+ per === 'line' ? (
+
+ {segment}
+
+ ) : per === 'word' ? (
+
+ {segment}
+
+ ) : (
+
+ {segment.split('').map((char, charIndex) => (
+
+ {char}
+
+ ))}
+
+ );
+
+ if (!segmentWrapperClassName) {
+ return content;
+ }
+
+ const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block';
+
+ return (
+
+ {content}
+
+ );
+});
+
+AnimationComponent.displayName = 'AnimationComponent';
+
+const splitText = (text: string, per: PerType) => {
+ if (per === 'line') return text.split('\n');
+ return text.split(/(\s+)/);
+};
+
+const hasTransition = (
+ variant?: Variant
+): variant is TargetAndTransition & { transition?: Transition } => {
+ if (!variant) return false;
+ return (
+ typeof variant === 'object' && 'transition' in variant
+ );
+};
+
+const createVariantsWithTransition = (
+ baseVariants: Variants,
+ transition?: Transition & { exit?: Transition }
+): Variants => {
+ if (!transition) return baseVariants;
+
+ const { exit: _, ...mainTransition } = transition;
+
+ return {
+ ...baseVariants,
+ visible: {
+ ...baseVariants.visible,
+ transition: {
+ ...(hasTransition(baseVariants.visible)
+ ? baseVariants.visible.transition
+ : {}),
+ ...mainTransition,
+ },
+ },
+ exit: {
+ ...baseVariants.exit,
+ transition: {
+ ...(hasTransition(baseVariants.exit)
+ ? baseVariants.exit.transition
+ : {}),
+ ...mainTransition,
+ staggerDirection: -1,
+ },
+ },
+ };
+};
+
+export function TextEffect({
+ children,
+ per = 'word',
+ as = 'p',
+ variants,
+ className,
+ preset = 'fade',
+ delay = 0,
+ speedReveal = 1,
+ speedSegment = 1,
+ trigger = true,
+ onAnimationComplete,
+ onAnimationStart,
+ segmentWrapperClassName,
+ containerTransition,
+ segmentTransition,
+ style,
+}: TextEffectProps) {
+ const segments = splitText(children, per);
+ const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
+
+ const baseVariants = preset
+ ? presetVariants[preset]
+ : { container: defaultContainerVariants, item: defaultItemVariants };
+
+ const stagger = defaultStaggerTimes[per] / speedReveal;
+
+ const baseDuration = 0.3 / speedSegment;
+
+ const customStagger = hasTransition(variants?.container?.visible ?? {})
+ ? (variants?.container?.visible as TargetAndTransition).transition
+ ?.staggerChildren
+ : undefined;
+
+ const customDelay = hasTransition(variants?.container?.visible ?? {})
+ ? (variants?.container?.visible as TargetAndTransition).transition
+ ?.delayChildren
+ : undefined;
+
+ const computedVariants = {
+ container: createVariantsWithTransition(
+ variants?.container || baseVariants.container,
+ {
+ staggerChildren: customStagger ?? stagger,
+ delayChildren: customDelay ?? delay,
+ ...containerTransition,
+ exit: {
+ staggerChildren: customStagger ?? stagger,
+ staggerDirection: -1,
+ },
+ }
+ ),
+ item: createVariantsWithTransition(variants?.item || baseVariants.item, {
+ duration: baseDuration,
+ ...segmentTransition,
+ }),
+ };
+
+ return (
+
+ {trigger && (
+
+ {per !== 'line' ? {children} : null}
+ {segments.map((segment, index) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/text-morph.tsx b/apps/web/components/text-morph.tsx
new file mode 100644
index 00000000..467dc999
--- /dev/null
+++ b/apps/web/components/text-morph.tsx
@@ -0,0 +1,74 @@
+'use client';
+import { cn } from '@lib/utils';
+import { AnimatePresence, motion, type Transition, type Variants } from 'motion/react';
+import { useMemo, useId } from 'react';
+
+export type TextMorphProps = {
+ children: string;
+ as?: React.ElementType;
+ className?: string;
+ style?: React.CSSProperties;
+ variants?: Variants;
+ transition?: Transition;
+};
+
+export function TextMorph({
+ children,
+ as: Component = 'p',
+ className,
+ style,
+ variants,
+ transition,
+}: TextMorphProps) {
+ const uniqueId = useId();
+
+ const characters = useMemo(() => {
+ const charCounts: Record = {};
+
+ return children.split('').map((char) => {
+ const lowerChar = char.toLowerCase();
+ charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
+
+ return {
+ id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
+ label: char === ' ' ? '\u00A0' : char,
+ };
+ });
+ }, [children, uniqueId]);
+
+ const defaultVariants: Variants = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+ };
+
+ const defaultTransition: Transition = {
+ type: 'spring',
+ stiffness: 280,
+ damping: 18,
+ mass: 0.3,
+ };
+
+ return (
+ // @ts-expect-error - style is optional
+
+
+ {characters.map((character) => (
+
+ {character.label}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 54d69020..057532f4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -57,13 +57,14 @@
"dotenv": "^16.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
+ "framer-motion": "^12.23.12",
"is-hotkey": "^0.2.0",
"isbot": "^5.1.28",
"lucide-react": "^0.525.0",
"motion": "^12.19.2",
"next": "15.3.0",
"next-themes": "^0.4.6",
- "nuqs": "^2.4.3",
+ "nuqs": "^2.5.2",
"posthog-js": "^1.257.0",
"random-word-slugs": "^0.1.7",
"react": "^19.1.0",
diff --git a/apps/web/public/images/gdrive.svg b/apps/web/public/images/gdrive.svg
new file mode 100644
index 00000000..a8cefd5b
--- /dev/null
+++ b/apps/web/public/images/gdrive.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/web/public/images/icon-16.png b/apps/web/public/images/icon-16.png
new file mode 100644
index 00000000..549d267d
Binary files /dev/null and b/apps/web/public/images/icon-16.png differ
diff --git a/apps/web/public/images/notion.svg b/apps/web/public/images/notion.svg
new file mode 100644
index 00000000..bf6442f7
--- /dev/null
+++ b/apps/web/public/images/notion.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/web/public/images/onedrive.svg b/apps/web/public/images/onedrive.svg
new file mode 100644
index 00000000..f7d7a6a6
--- /dev/null
+++ b/apps/web/public/images/onedrive.svg
@@ -0,0 +1 @@
+OfficeCore10_32x_24x_20x_16x_01-22-2019
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index 7e4ca681..1d76152f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -121,13 +121,14 @@
"dotenv": "^16.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
+ "framer-motion": "^12.23.12",
"is-hotkey": "^0.2.0",
"isbot": "^5.1.28",
"lucide-react": "^0.525.0",
"motion": "^12.19.2",
"next": "15.3.0",
"next-themes": "^0.4.6",
- "nuqs": "^2.4.3",
+ "nuqs": "^2.5.2",
"posthog-js": "^1.257.0",
"random-word-slugs": "^0.1.7",
"react": "^19.1.0",
@@ -224,6 +225,7 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
@@ -1050,6 +1052,8 @@
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+ "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
+
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
@@ -3390,7 +3394,7 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
- "nuqs": ["nuqs@2.5.1", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-YvAyI01gaEfS6U2iTcfffKccGkqYRnGmLoCHvDjK4ShgtB0tKmYgC7+ez9PmdaiDmrLR+y1qHzfQC66T0VFwWQ=="],
+ "nuqs": ["nuqs@2.5.2", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-vzKeoYlMRmNYPWECdn53Nmh/jM+r/iSezEin342EVXPogT6KzALwdnYbZxASE5vTdXRUtOymtPkgsarLipKetg=="],
"nypm": ["nypm@0.6.1", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w=="],
diff --git a/packages/lib/api.ts b/packages/lib/api.ts
index ad343050..bfdc3ac0 100644
--- a/packages/lib/api.ts
+++ b/packages/lib/api.ts
@@ -187,6 +187,11 @@ export const apiSchema = createSchema({
}),
},
+ // MCP operations
+ "@get/mcp/has-login": {
+ output: z.object({ previousLogin: z.boolean() }),
+ },
+
// Waitlist operations
"@get/waitlist/status": {
output: WaitlistStatusResponseSchema,
diff --git a/packages/ui/components/carousel.tsx b/packages/ui/components/carousel.tsx
index 2462c404..2326bd02 100644
--- a/packages/ui/components/carousel.tsx
+++ b/packages/ui/components/carousel.tsx
@@ -181,8 +181,8 @@ function CarouselPrevious({
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
- ? "top-1/2 -left-12 -translate-y-1/2"
- : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
+ ? "top-1/2 left-2 -translate-y-1/2"
+ : "top-2 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
data-slot="carousel-previous"
@@ -211,8 +211,8 @@ function CarouselNext({
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
- ? "top-1/2 -right-12 -translate-y-1/2"
- : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
+ ? "top-1/2 right-2 -translate-y-1/2"
+ : "bottom-2 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
data-slot="carousel-next"
diff --git a/packages/ui/components/hover-card.tsx b/packages/ui/components/hover-card.tsx
new file mode 100644
index 00000000..bdf3ce94
--- /dev/null
+++ b/packages/ui/components/hover-card.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
+
+import { cn } from "@lib/utils"
+
+function HoverCard({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function HoverCardTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function HoverCardContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 2a1bfd1b..7c2a564a 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -12,6 +12,7 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",