feat(web): redesign login with split layout and context network panel (#1010)

Consolidate login routes, add animated tool context visualization on the hero panel, and polish loading states across onboarding, settings, and workspace bootstrap.
This commit is contained in:
MaheshtheDev 2026-05-27 00:59:32 +00:00
parent 9229c60a61
commit 31d416b04a
16 changed files with 1541 additions and 689 deletions

View file

@ -555,6 +555,7 @@ export default function OnboardingPage() {
const fileRef = useRef<HTMLInputElement>(null)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const skippingRef = useRef(false)
const [isSkipping, setIsSkipping] = useState(false)
const [spotlightCategory, setSpotlightCategory] =
useState<SpotlightCategoryId>("productivity")
@ -617,6 +618,7 @@ export default function OnboardingPage() {
const handleSkip = useCallback(async () => {
if (skippingRef.current) return
skippingRef.current = true
setIsSkipping(true)
try {
await ensureOrg()
const pendingPath = consumePendingConnectUrl()
@ -624,6 +626,7 @@ export default function OnboardingPage() {
} catch (err) {
console.error(err)
skippingRef.current = false
setIsSkipping(false)
}
}, [ensureOrg, router])
@ -798,9 +801,22 @@ export default function OnboardingPage() {
<button
type="button"
onClick={handleSkip}
className="text-[#525966] text-sm hover:text-white transition-colors cursor-pointer"
disabled={isSkipping}
className={cn(
"text-sm transition-colors cursor-pointer flex items-center gap-1.5",
isSkipping
? "text-[#525966] cursor-not-allowed"
: "text-[#525966] hover:text-white",
)}
>
Skip for now
{isSkipping ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Skipping
</>
) : (
"Skip for now →"
)}
</button>
</div>
@ -870,34 +886,36 @@ export default function OnboardingPage() {
/>
<AnimatePresence>
{isCheckingAccount && (
{hasDetectedAccount && detected !== "resume" && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="absolute right-3 flex items-center pointer-events-none"
className="absolute right-1 flex size-8 items-center justify-center"
>
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
{isCheckingAccount ? (
<Loader2 className="size-4 animate-spin text-[#6BB0FF]" />
) : canSubmit ? (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={() =>
handleSubmit(detected as "x" | "linkedin")
}
className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
style={{
background:
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
>
<SubmitArrow />
</motion.button>
) : null}
</motion.span>
)}
</AnimatePresence>
{canSubmit && (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={() => handleSubmit(detected as "x" | "linkedin")}
className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer"
style={{
background:
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}}
>
<SubmitArrow />
</motion.button>
)}
</div>
<AnimatePresence>
@ -929,9 +947,6 @@ export default function OnboardingPage() {
{currentAccountLookup?.status === "error" && (
<AlertCircle className="size-3.5 shrink-0" />
)}
{isCheckingAccount && (
<Loader2 className="size-3.5 shrink-0 animate-spin" />
)}
<span>
{currentAccountLookup?.message ??
SOURCE_LABEL[detected as "x" | "linkedin"]}

View file

@ -298,92 +298,99 @@ export default function SettingsPage() {
<span className="text-white/55 font-medium shrink-0">Settings</span>
</nav>
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
{!isMobile && (
<Popover
open={orgSwitcherOpen && canSwitchOrg}
onOpenChange={(open) => {
if (canSwitchOrg) setOrgSwitcherOpen(open)
}}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={!canSwitchOrg}
{!isMobile &&
(canSwitchOrg ? (
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
"bg-white/[0.03] border border-white/[0.06]",
"cursor-pointer hover:bg-white/[0.06]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
className={cn(
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
"bg-white/[0.03] border border-white/[0.06]",
canSwitchOrg
? "cursor-pointer hover:bg-white/[0.06]"
: "cursor-default",
"w-[260px] max-h-80 overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
{canSwitchOrg && (
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
)}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
{[...(organizations ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
isCurrent,
currentPlan,
planByOrgId,
)
return (
<button
key={organization.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() =>
handleOrgSwitch(organization.slug, organization.id)
}
className={cn(
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
isCurrent
? "bg-white/5"
: "hover:bg-white/5 cursor-pointer",
"disabled:cursor-default",
)}
>
<Building2 className="size-4 shrink-0 text-white/40" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
{organization.name}
</span>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : null}
<OrgPlanBadge plan={plan} />
</button>
)
})}
</PopoverContent>
</Popover>
) : (
<div
className={cn(
"w-[260px] max-h-80 overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
"bg-white/[0.03] border border-white/[0.06]",
dmSansClassName(),
)}
>
{[...(organizations ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
isCurrent,
currentPlan,
planByOrgId,
)
return (
<button
key={organization.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() =>
handleOrgSwitch(organization.slug, organization.id)
}
className={cn(
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
isCurrent
? "bg-white/5"
: "hover:bg-white/5 cursor-pointer",
"disabled:cursor-default",
)}
>
<Building2 className="size-4 shrink-0 text-white/40" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
{organization.name}
</span>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : null}
<OrgPlanBadge plan={plan} />
</button>
)
})}
</PopoverContent>
</Popover>
)}
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
</div>
))}
<UserProfileMenu />
</div>
</header>
@ -639,6 +646,11 @@ export default function SettingsPage() {
resetConfirmation !== confirmText ||
resetOrganization.isPending
}
title={
!confirmText || resetConfirmation !== confirmText
? `Type "${confirmText || "your name"}" exactly to enable`
: undefined
}
onClick={() =>
resetOrganization.mutate(
{ confirmation: confirmText },
@ -719,6 +731,11 @@ export default function SettingsPage() {
deleteEmailConfirm !== user?.email ||
deleteUserAccount.isPending
}
title={
deleteEmailConfirm !== user?.email
? `Type ${user?.email ?? "your email"} exactly to confirm`
: undefined
}
onClick={handleDeleteAccount}
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>

View file

@ -1,531 +1,26 @@
"use client"
import { redirect } from "next/navigation"
import { signIn, useSession } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { InitialHeader } from "@/components/initial-header"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
}
function buildMcpAuthorizeResumeUrl(
sp: Pick<URLSearchParams, "toString">,
function serializeSearchParams(
sp: Record<string, string | string[] | undefined>,
): string {
const backend =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const p = new URLSearchParams(sp.toString())
p.delete("redirect")
p.delete("error")
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
const q = new URLSearchParams()
for (const [key, value] of Object.entries(sp)) {
if (value === undefined) continue
if (Array.isArray(value)) {
for (const v of value) q.append(key, v)
} else {
q.set(key, value)
}
}
return q.toString()
}
function AnimatedGradientBackground() {
return (
<div className="fixed inset-0 z-0 overflow-hidden bg-[#030912]">
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: [1, 0, 1],
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: [0, 1, 0],
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.4,
}}
/>
</div>
)
}
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
className="flex py-8 px-11 flex-col items-start gap-2 rounded-[22px] bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
{children}
</motion.div>
)
}
export default function LoginPage() {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
const { data: sessionData, isPending: sessionPending } = useSession()
const oauthQueryForResume = params.toString()
useEffect(() => {
if (sessionPending) return
if (!sessionData?.session) return
const sp = new URLSearchParams(oauthQueryForResume)
if (isMcpOAuthAuthorizeContext(sp)) {
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
return
}
const redirectUrl = params.get("redirect")
if (redirectUrl) {
window.location.assign(
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
)
return
}
router.replace("/")
}, [
sessionPending,
sessionData?.session,
oauthQueryForResume,
params,
router,
])
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin
if (isMcpOAuthAuthorizeContext(params)) {
return buildMcpAuthorizeResumeUrl(params)
}
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method")
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem("supermemory-pending-login-method", method)
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
)
} catch {}
}
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes("load failed") ||
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("network request failed")
)
}
function getErrorMessage(error: unknown): string {
if (isNetworkError(error)) {
return "Network error. Please check your connection and try again."
}
if (error instanceof Error) {
return error.message
}
return "An unexpected error occurred. Please try again."
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch {}
}
}, [params])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
const { error } = await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
if (error) {
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
is_network_error: isNetworkError(error),
})
setError(getErrorMessage(error))
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}
return (
<main className="relative h-screen overflow-hidden">
<AnimatedGradientBackground />
<div className="relative z-10">
<InitialHeader />
<section className="flex flex-col items-center justify-center p-4 space-y-12 sm:p-6 md:p-8 lg:px-20 lg:py-12.5 min-h-[calc(100vh-80px)]">
<div className="text-center">
<div className="text-5xl font-medium">
Never forget anything, anywhere
</div>
<div className="text-5xl font-medium">with supermemory</div>
</div>
{submittedEmail ? (
<LoginCard>
<div className="w-[360px] flex flex-col gap-4 lg:gap-6 min-h-2/3">
<div className="flex flex-col gap-2 text-center lg:text-left">
<Title1Bold className="text-foreground">
Almost there!
</Title1Bold>
<HeadingH3Medium className="text-muted-foreground">
Click the magic link we've sent to{" "}
<span className="text-foreground">{submittedEmail}</span>.
</HeadingH3Medium>
</div>
<TextSeparator text="OR" className={cn(dmSansClassName())} />
<form
className="flex flex-col gap-4 lg:gap-6"
onSubmit={handleSubmitToken}
>
<LabeledInput
inputPlaceholder="your temporary login code"
inputProps={{
name: "token",
required: true,
disabled: isLoading,
"aria-invalid": error ? "true" : "false",
}}
inputType="text"
label="Enter code"
/>
<Button disabled={isLoading} id="verify-token" type="submit">
Verify Token
</Button>
</form>
</div>
</LoginCard>
) : (
<LoginCard>
<div className="w-[360px] flex flex-col" style={{ gap: "12px" }}>
{params.get("error") && (
<div className="text-red-500">
Error: {params.get("error")}. Please try again!
</div>
)}
<div className="flex flex-col gap-3">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="relative grow">
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#FFC107"
/>
<path
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
fill="#FF3D00"
/>
<path
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
fill="#4CAF50"
/>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod("google")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "google",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === "google" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="relative grow">
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
fill="currentColor"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.21)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod("github")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "github",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === "github" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
</div>
<TextSeparator text="OR" className={cn(dmSansClassName())} />
<div className="flex flex-col gap-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<LabeledInput
error={error}
inputPlaceholder="your@email.com"
inputProps={{
"aria-invalid": error ? "true" : "false",
disabled: isLoading,
id: "email",
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
}}
inputType="email"
/>
<div className="relative">
<Button
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={isLoading}
type="submit"
>
<Logo className="size-4" />
{isLoadingEmail
? "Sending login link..."
: "Log in with Supermemory"}
</Button>
{lastUsedMethod === "magic_link" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
</form>
<Label1Regular
className={cn(
"text-center text-xs! text-[#737373B2]",
dmSansClassName(),
)}
>
By continuing, you agree to our{" "}
<span className="inline-block">
<a
className="underline"
href="https://supermemory.ai/terms/"
>
Terms
</a>{" "}
and{" "}
<a
className="underline"
href="https://supermemory.ai/privacy/"
>
Privacy Policy
</a>
.
</span>
</Label1Regular>
</div>
</div>
</LoginCard>
)}
</section>
</div>
</main>
)
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const sp = await searchParams
const query = serializeSearchParams(sp)
redirect(query ? `/login?${query}` : "/login")
}

View file

@ -1,26 +1,548 @@
import { redirect } from "next/navigation"
"use client"
function serializeSearchParams(
sp: Record<string, string | string[] | undefined>,
import { signIn, useSession } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import { InitialHeader } from "@/components/initial-header"
import { LoginToolsPanel } from "@/components/login-tools-panel"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
import { Loader2 } from "lucide-react"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
}
function buildMcpAuthorizeResumeUrl(
sp: Pick<URLSearchParams, "toString">,
): string {
const q = new URLSearchParams()
for (const [key, value] of Object.entries(sp)) {
if (value === undefined) continue
if (Array.isArray(value)) {
for (const v of value) q.append(key, v)
} else {
q.set(key, value)
}
}
return q.toString()
const backend =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const p = new URLSearchParams(sp.toString())
p.delete("redirect")
p.delete("error")
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
}
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const sp = await searchParams
const query = serializeSearchParams(sp)
redirect(query ? `/login/new?${query}` : "/login/new")
function LoginHeadline({ className }: { className?: string }) {
return (
<div className={cn("max-w-sm text-center", className)}>
<h1 className="text-balance text-lg font-medium leading-tight tracking-tight sm:text-xl lg:text-[1.375rem] xl:text-2xl">
Never forget anything, anywhere
<span className="block text-brand-accent">with supermemory</span>
</h1>
<p
className={cn(
"mt-1.5 text-xs leading-snug text-muted-foreground/50 lg:mt-2",
dmSansClassName(),
)}
>
Save from Chrome, Notion, X search it all in one place.
</p>
</div>
)
}
function LastUsedBadge({ show }: { show: boolean }) {
if (!show) return null
return (
<div className="mb-1 flex justify-end">
<Badge variant="default" className="h-5 px-1.5 text-[10px]">
Last used
</Badge>
</div>
)
}
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
className="flex w-full max-w-[400px] shrink-0 flex-col items-center gap-2 rounded-[22px] py-5 px-5 sm:w-auto sm:max-w-none sm:items-start sm:py-8 sm:px-11 bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
>
{children}
</motion.div>
)
}
function LoginCardBody({
children,
loadingMessage,
}: {
children: React.ReactNode
loadingMessage?: string | null
}) {
return (
<div className="relative w-full max-w-[360px] sm:w-[360px]">
<div className={loadingMessage ? "invisible" : undefined}>{children}</div>
{loadingMessage ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-[14px] bg-[#030912]/90 backdrop-blur-sm">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
</div>
) : null}
</div>
)
}
export default function LoginPage() {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
const { data: sessionData, isPending: sessionPending } = useSession()
const oauthQueryForResume = params.toString()
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
const isAuthResolving = sessionPending || isRedirecting
const loadingMessage = isAuthResolving
? sessionPending
? "Checking session…"
: "Redirecting…"
: isLoading
? isLoadingEmail
? "Sending login link…"
: "Redirecting…"
: null
useEffect(() => {
if (sessionPending) return
if (!sessionData?.session) return
const sp = new URLSearchParams(oauthQueryForResume)
if (isMcpOAuthAuthorizeContext(sp)) {
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
return
}
const redirectUrl = params.get("redirect")
if (redirectUrl) {
window.location.assign(
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
)
return
}
router.replace("/")
}, [
sessionPending,
sessionData?.session,
oauthQueryForResume,
params,
router,
])
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin
if (isMcpOAuthAuthorizeContext(params)) {
return buildMcpAuthorizeResumeUrl(params)
}
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method")
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem("supermemory-pending-login-method", method)
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
)
} catch {}
}
function isNetworkError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return (
message.includes("load failed") ||
message.includes("networkerror") ||
message.includes("failed to fetch") ||
message.includes("network request failed")
)
}
function getErrorMessage(error: unknown): string {
if (isNetworkError(error)) {
return "Network error. Please check your connection and try again."
}
if (error instanceof Error) {
return error.message
}
return "An unexpected error occurred. Please try again."
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch {}
}
}, [params])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
const { error } = await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
if (error) {
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
is_network_error: isNetworkError(error),
})
setError(getErrorMessage(error))
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}
return (
<main className="login-page-grid relative bg-[#030912]">
<div className="login-page-mobile-bg" aria-hidden>
<div className="login-panel-orb h-full" />
<div className="login-panel-orb-image h-full" />
</div>
<div className="relative z-20 lg:hidden">
<InitialHeader />
</div>
<LoginToolsPanel />
<div className="relative z-10 flex min-h-0 min-w-0 flex-col lg:col-start-2 lg:row-start-1 lg:h-full">
<div className="hidden lg:block">
<InitialHeader />
</div>
<section className="flex min-h-0 flex-1 flex-col items-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-1 lg:justify-center lg:px-10 lg:py-10">
<div className="mt-auto flex w-full flex-col items-center lg:mt-0">
<LoginHeadline className="mb-3 sm:mb-4 lg:mb-8" />
<LoginCard>
<LoginCardBody loadingMessage={loadingMessage}>
{submittedEmail ? (
<div className="flex flex-col gap-4 lg:gap-6">
<div className="flex flex-col gap-2 text-center lg:text-left">
<Title1Bold className="text-foreground">
Almost there!
</Title1Bold>
<HeadingH3Medium className="text-muted-foreground">
Click the magic link we've sent to{" "}
<span className="text-foreground">
{submittedEmail}
</span>
.
</HeadingH3Medium>
</div>
<TextSeparator
text="OR"
className={cn(dmSansClassName())}
/>
<form
className="flex flex-col gap-4 lg:gap-6"
onSubmit={handleSubmitToken}
>
<LabeledInput
inputPlaceholder="your temporary login code"
inputProps={{
name: "token",
required: true,
disabled: isLoading,
"aria-invalid": error ? "true" : "false",
}}
inputType="text"
label="Enter code"
/>
<Button
disabled={isLoading}
id="verify-token"
type="submit"
>
Verify Token
</Button>
</form>
</div>
) : (
<div className="flex flex-col" style={{ gap: "12px" }}>
{params.get("error") && (
<div className="text-red-500">
Error: {params.get("error")}. Please try again!
</div>
)}
<div className="flex flex-col gap-3">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "google"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#FFC107"
/>
<path
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
fill="#FF3D00"
/>
<path
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
fill="#4CAF50"
/>
<path
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod("google")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "google",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "github"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
fill="currentColor"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.21)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod("github")
signIn
.social({
callbackURL: getCallbackURL(),
provider: "github",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
</div>
<TextSeparator
text="OR"
className={cn(dmSansClassName())}
/>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit}
className="flex flex-col gap-6"
>
<LabeledInput
error={error}
inputPlaceholder="your@email.com"
inputProps={{
"aria-invalid": error ? "true" : "false",
disabled: Boolean(loadingMessage),
id: "email",
onChange: (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
}}
inputType="email"
/>
<div className="w-full">
<LastUsedBadge
show={lastUsedMethod === "magic_link"}
/>
<Button
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={Boolean(loadingMessage)}
type="submit"
>
<Logo className="size-4" />
Log in with Supermemory
</Button>
</div>
</form>
<Label1Regular
className={cn(
"text-center text-xs! text-[#737373B2]",
dmSansClassName(),
)}
>
By continuing, you agree to our{" "}
<span className="inline-block">
<a
className="underline"
href="https://supermemory.ai/terms/"
>
Terms
</a>{" "}
and{" "}
<a
className="underline"
href="https://supermemory.ai/privacy/"
>
Privacy Policy
</a>
.
</span>
</Label1Regular>
</div>
</div>
)}
</LoginCardBody>
</LoginCard>
</div>
</section>
</div>
</main>
)
}

View file

@ -1,5 +1,6 @@
import { cn } from "@lib/utils"
import { SquareIcon } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
export function SendButton({
onClick,
@ -8,7 +9,7 @@ export function SendButton({
onClick: () => void
disabled: boolean
}) {
return (
const button = (
<button
type="button"
onClick={onClick}
@ -35,6 +36,19 @@ export function SendButton({
</svg>
</button>
)
if (disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{button}</span>
</TooltipTrigger>
<TooltipContent side="top">Type a message to send</TooltipContent>
</Tooltip>
)
}
return button
}
export function StopButton({ onClick }: { onClick: () => void }) {

View file

@ -1361,6 +1361,7 @@ export function DashboardView({
onChat={onHighlightsChat}
onShowRelated={onHighlightsShowRelated}
isLoading={isLoadingHighlights}
onAddMemory={() => onAddMemory("note")}
/>
</div>
<div className="flex-[2] hidden sm:block min-w-0">

View file

@ -3,6 +3,18 @@
import { useEffect } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useAuth } from "@lib/auth-context"
import { Loader2 } from "lucide-react"
function WorkspaceLoadingShell() {
return (
<div className="flex min-h-dvh items-center justify-center bg-[#05080D]">
<Loader2
className="size-6 animate-spin text-white/40"
aria-label="Loading workspace"
/>
</div>
)
}
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
@ -11,6 +23,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const { session, organizations, isRestoring } = useAuth()
const isMcpPublicPage = searchParams.get("view") === "mcp"
const isOnboarding = pathname.startsWith("/onboarding")
useEffect(() => {
if (isMcpPublicPage) return
@ -23,9 +36,30 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
}
if (organizations === null) return
if (organizations.length > 0) return
if (pathname.startsWith("/onboarding")) return
if (isOnboarding) return
router.replace("/onboarding")
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
}, [
session,
organizations,
isRestoring,
isOnboarding,
router,
isMcpPublicPage,
])
const showLoading =
!isMcpPublicPage &&
(isRestoring ||
(!session && !isRestoring) ||
(session && organizations === null) ||
(session &&
organizations !== null &&
organizations.length === 0 &&
!isOnboarding))
if (showLoading) {
return <WorkspaceLoadingShell />
}
return children
}

View file

@ -508,7 +508,7 @@ export function PublicHeader() {
Sign in
</button>
</Link>
<Link href="/login/new">
<Link href="/login">
<button
type="button"
className={cn(

View file

@ -32,6 +32,7 @@ interface HighlightsCardProps {
onChat: (highlightContent: string, userReply: string) => void
onShowRelated: (query: string) => void
isLoading?: boolean
onAddMemory?: () => void
}
function renderContent(content: string, format: HighlightFormat) {
@ -69,6 +70,7 @@ export function HighlightsCard({
onChat,
onShowRelated,
isLoading = false,
onAddMemory,
}: HighlightsCardProps) {
const [activeIndex, setActiveIndex] = useState(0)
const [isReplyOpen, setIsReplyOpen] = useState(false)
@ -186,10 +188,19 @@ export function HighlightsCard({
</div>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="flex-1 flex flex-col items-center justify-center gap-3">
<p className="text-[11px] text-fg-muted text-center">
Add some documents to see highlights here
</p>
{onAddMemory ? (
<button
type="button"
onClick={onAddMemory}
className="text-[11px] font-medium text-brand-accent hover:text-brand-accent/80 transition-colors cursor-pointer"
>
Add memory
</button>
) : null}
</div>
</div>
)

View file

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { analytics } from "@/lib/analytics"
import { consumePendingConnectUrl } from "@/lib/constants"
import { cn } from "@lib/utils"
export function InitialHeader({
showUserSupermemory,
@ -28,10 +29,10 @@ export function InitialHeader({
}
return (
<div className="flex p-6 justify-between items-center">
<div className="flex items-center z-10!">
<Logo className="h-7" />
{showUserSupermemory && (
<div className="flex items-center justify-between p-4 sm:p-6">
<div className="flex items-center z-10! min-w-0">
<Logo className="h-6 sm:h-7 shrink-0" />
{showUserSupermemory ? (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{userName}
@ -40,9 +41,13 @@ export function InitialHeader({
supermemory
</p>
</div>
) : (
<p className="ml-2 truncate text-xl leading-none font-semibold text-white/90">
supermemory
</p>
)}
</div>
<div className="flex items-center gap-3 z-10!">
<div className="flex items-center gap-2 sm:gap-3 z-10! shrink-0">
{showSkipOnboarding && !isLoading && (
<button
type="button"
@ -54,10 +59,21 @@ export function InitialHeader({
)}
<Button
variant="newDefault"
className="rounded-2xl text-base gap-1 h-11!"
className={cn(
"rounded-xl sm:rounded-2xl gap-1 h-9 sm:h-11 px-3 sm:px-4 text-sm sm:text-base",
)}
size="lg"
onClick={() =>
window.open(
"https://console.supermemory.ai",
"_blank",
"noopener,noreferrer",
)
}
>
Memory API <span className="text-xs mt-[4px]"></span>
<span className="sm:hidden">API</span>
<span className="hidden sm:inline">Memory API</span>{" "}
<span className="text-xs mt-[4px]"></span>
</Button>
</div>
</div>

View file

@ -0,0 +1,399 @@
"use client"
import Image from "next/image"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import {
ClaudeDesktopIcon,
GoogleDrive,
MCPIcon,
Notion,
} from "@ui/assets/icons"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
type ToolNode = {
id: string
name: string
x: number
y: number
icon?: React.ComponentType<{ className?: string }>
iconSrc?: string
}
type ContextConnection = {
from: ToolNode
to: ToolNode
}
type ContextPhase = "idle" | "capture" | "hold" | "recall"
const CENTER = { x: 50, y: 50 }
const IN_MS = 1100
const HOLD_MS = 700
const OUT_MS = 1100
const TOTAL_MS = IN_MS + HOLD_MS + OUT_MS
const TOOL_NODES: ToolNode[] = [
{ id: "chrome", name: "Chrome", x: 14, y: 20, icon: ChromeIcon },
{ id: "notion", name: "Notion", x: 84, y: 16, icon: Notion },
{ id: "drive", name: "Google Drive", x: 10, y: 52, icon: GoogleDrive },
{ id: "claude", name: "Claude", x: 90, y: 44, icon: ClaudeDesktopIcon },
{ id: "raycast", name: "Raycast", x: 76, y: 76, icon: RaycastIcon },
{ id: "mcp", name: "MCP", x: 22, y: 84, icon: MCPIcon },
{
id: "claude-code",
name: "Claude Code",
x: 20,
y: 36,
iconSrc: "/images/plugins/claude-code.svg",
},
{
id: "codex",
name: "Codex",
x: 92,
y: 28,
iconSrc: "/images/plugins/codex.png",
},
{
id: "opencode",
name: "OpenCode",
x: 58,
y: 10,
iconSrc: "/images/plugins/opencode.svg",
},
{
id: "hermes",
name: "Hermes",
x: 36,
y: 90,
iconSrc: "/images/plugins/hermes.svg",
},
{
id: "openclaw",
name: "OpenClaw",
x: 68,
y: 68,
iconSrc: "/images/plugins/openclaw.svg",
},
]
const CONTEXT_FLOWS: [string, string][] = [
["chrome", "claude"],
["notion", "raycast"],
["drive", "claude-code"],
["opencode", "codex"],
["claude-code", "mcp"],
["hermes", "openclaw"],
["claude", "mcp"],
["notion", "claude"],
]
function nodeById(id: string) {
return TOOL_NODES.find((node) => node.id === id)
}
function pickContextFlow(): ContextConnection | null {
const flow = CONTEXT_FLOWS[Math.floor(Math.random() * CONTEXT_FLOWS.length)]
if (!flow) return null
const [fromId, toId] = flow
const from = nodeById(fromId)
const to = nodeById(toId)
if (!from || !to) return null
return { from, to }
}
function ToolNodeIcon({
node,
role,
}: {
node: ToolNode
role?: "source" | "destination"
}) {
const Icon = node.icon
return (
<div
className="login-tool-node-wrap"
style={{ left: `${node.x}%`, top: `${node.y}%` }}
>
<div
className={cn(
"login-tool-node",
role === "source" && "login-tool-node-source",
role === "destination" && "login-tool-node-destination",
)}
title={node.name}
>
{Icon ? (
<Icon className="login-tool-node-icon shrink-0" />
) : node.iconSrc ? (
<Image
src={node.iconSrc}
alt=""
width={22}
height={22}
className="login-tool-node-icon shrink-0"
/>
) : null}
</div>
<span
className={cn(
"login-tool-node-label",
dmSansClassName(),
role && "login-tool-node-label-visible",
)}
aria-hidden={!role}
>
{node.name}
</span>
</div>
)
}
function MemoryChip() {
return (
<div className="login-context-chip">
<div className="login-context-chip-lines" aria-hidden>
<span />
<span />
<span />
</div>
</div>
)
}
function AnimatedContextFlow({
connection,
phase,
}: {
connection: ContextConnection
phase: ContextPhase
}) {
const { from, to } = connection
const dIn = `M ${from.x} ${from.y} L ${CENTER.x} ${CENTER.y}`
const dOut = `M ${CENTER.x} ${CENTER.y} L ${to.x} ${to.y}`
const chipLeft =
phase === "recall"
? [`${CENTER.x}%`, `${to.x}%`]
: phase === "hold"
? `${CENTER.x}%`
: [`${from.x}%`, `${CENTER.x}%`]
const chipTop =
phase === "recall"
? [`${CENTER.y}%`, `${to.y}%`]
: phase === "hold"
? `${CENTER.y}%`
: [`${from.y}%`, `${CENTER.y}%`]
return (
<div className="pointer-events-none absolute inset-0 z-[1]">
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d={dIn}
fill="none"
stroke="rgb(75 160 250 / 0.12)"
strokeWidth="2.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<path
d={dOut}
fill="none"
stroke="rgb(75 160 250 / 0.12)"
strokeWidth="2.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{(phase === "capture" || phase === "hold") && (
<motion.path
key={`in-${from.id}`}
d={dIn}
fill="none"
stroke="rgb(140 205 255 / 0.75)"
strokeWidth="1.75"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: IN_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
/>
)}
{phase === "recall" && (
<motion.path
key={`out-${to.id}`}
d={dOut}
fill="none"
stroke="rgb(160 220 255 / 0.9)"
strokeWidth="1.75"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: OUT_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
/>
)}
</svg>
{phase !== "idle" && (
<motion.div
className="absolute z-[2] -translate-x-1/2 -translate-y-1/2"
initial={false}
animate={{
left: chipLeft,
top: chipTop,
opacity: phase === "hold" ? 1 : [1, 1],
scale: phase === "hold" ? 1.05 : 1,
}}
transition={{
left: {
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
ease: [0.35, 0, 0.15, 1],
},
top: {
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
ease: [0.35, 0, 0.15, 1],
},
scale: { duration: 0.3 },
}}
>
<MemoryChip />
</motion.div>
)}
</div>
)
}
function ToolsContextNetwork() {
const [connection, setConnection] = useState<ContextConnection | null>(null)
const [pulseId, setPulseId] = useState(0)
const [phase, setPhase] = useState<ContextPhase>("idle")
useEffect(() => {
let cancelled = false
let pulseTimeout: ReturnType<typeof setTimeout>
const runPulse = () => {
if (cancelled) return
const next = pickContextFlow()
if (!next) return
setConnection(next)
setPulseId((id) => id + 1)
pulseTimeout = setTimeout(runPulse, TOTAL_MS + 900 + Math.random() * 500)
}
runPulse()
return () => {
cancelled = true
clearTimeout(pulseTimeout)
}
}, [])
useEffect(() => {
if (!connection) return
setPhase("capture")
const holdTimer = setTimeout(() => setPhase("hold"), IN_MS)
const recallTimer = setTimeout(() => setPhase("recall"), IN_MS + HOLD_MS)
const idleTimer = setTimeout(() => setPhase("idle"), TOTAL_MS)
return () => {
clearTimeout(holdTimer)
clearTimeout(recallTimer)
clearTimeout(idleTimer)
}
}, [connection])
const sourceRole = (nodeId: string) =>
connection &&
connection.from.id === nodeId &&
(phase === "capture" || phase === "hold")
? ("source" as const)
: undefined
const destRole = (nodeId: string) =>
connection && connection.to.id === nodeId && phase === "recall"
? ("destination" as const)
: undefined
return (
<div
className="login-tools-network relative h-full min-h-[240px] w-full lg:min-h-0"
aria-hidden
>
{connection && phase !== "idle" ? (
<AnimatedContextFlow
key={pulseId}
connection={connection}
phase={phase}
/>
) : null}
{TOOL_NODES.map((node) => (
<ToolNodeIcon
key={node.id}
node={node}
role={sourceRole(node.id) ?? destRole(node.id)}
/>
))}
<div className="absolute left-1/2 top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2">
<motion.div
className="relative flex size-24 items-center justify-center sm:size-28 lg:size-36"
animate={{ scale: phase === "hold" ? [1, 1.08, 1] : 1 }}
transition={{ duration: 0.55, ease: "easeOut" }}
>
<NovaOrb size={112} className="blur-[2px]!" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo className="size-6 opacity-80 sm:size-7 lg:size-8" />
</div>
</motion.div>
</div>
</div>
)
}
function LoginPanelBackground() {
return (
<>
<div
className="pointer-events-none absolute inset-0 bg-[#030912]"
aria-hidden
/>
<div className="login-panel-orb pointer-events-none" aria-hidden />
<div className="login-panel-orb-image" aria-hidden />
<div className="login-panel-orb-image-alt" aria-hidden />
</>
)
}
export function LoginToolsPanel() {
return (
<aside className="relative hidden min-h-0 flex-col overflow-hidden border-white/[0.06] lg:col-start-1 lg:row-start-1 lg:flex lg:h-full lg:border-r">
<LoginPanelBackground />
<div className="login-tools-panel-inner relative z-10 flex flex-col justify-center px-4 py-8 sm:px-8 lg:px-10">
<ToolsContextNetwork />
</div>
<p
className={cn(
"relative z-10 shrink-0 px-4 pb-4 text-center text-[11px] leading-snug text-white/40 sm:px-8 sm:text-xs lg:absolute lg:bottom-6 lg:left-1/2 lg:max-w-none lg:-translate-x-1/2 lg:px-0 lg:pb-0 lg:text-sm lg:whitespace-nowrap lg:text-white/45",
dmSansClassName(),
)}
>
One memory layer context from any tool, everywhere you need it.
</p>
</aside>
)
}

View file

@ -438,35 +438,25 @@ export default function Account() {
>
Organization
</p>
<Popover
open={orgMenuOpen && canSwitchOrg}
onOpenChange={(open) => {
if (canSwitchOrg) setOrgMenuOpen(open)
}}
>
<PopoverTrigger
disabled={!canSwitchOrg}
className={cn(
"flex min-w-0 max-w-full items-center gap-2 transition-opacity",
canSwitchOrg
? "cursor-pointer hover:opacity-90"
: "cursor-default",
dmSans125ClassName(),
)}
>
<span
{canSwitchOrg ? (
<Popover open={orgMenuOpen} onOpenChange={setOrgMenuOpen}>
<PopoverTrigger
className={cn(
"flex min-w-0 max-w-full items-center gap-2 transition-opacity",
"cursor-pointer hover:opacity-90",
dmSans125ClassName(),
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{org?.name ?? "Personal"}
</span>
{canSwitchOrg && (
<span
className={cn(
dmSans125ClassName(),
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{org?.name ?? "Personal"}
</span>
<ChevronDown className="size-4 shrink-0 text-[#737373]" />
)}
</PopoverTrigger>
{canSwitchOrg && (
</PopoverTrigger>
<PopoverContent
align="start"
className="w-80 max-h-80 overflow-y-auto bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
@ -516,8 +506,17 @@ export default function Account() {
)
})}
</PopoverContent>
)}
</Popover>
</Popover>
) : (
<span
className={cn(
dmSans125ClassName(),
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{org?.name ?? "Personal"}
</span>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p
@ -1179,6 +1178,13 @@ export default function Account() {
inviteMemberMutation.isPending ||
(showTagPicker && inviteAssignments.length === 0)
}
title={
showTagPicker && inviteAssignments.length === 0
? "Select at least one space for restricted access"
: !inviteEmail.trim()
? "Enter an email address to send an invite"
: undefined
}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",

View file

@ -674,6 +674,11 @@ export default function Billing() {
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || isCheckingStatus || autumn.isLoading}
title={
autumn.isLoading && !isUpgrading && !isCheckingStatus
? "Loading billing details…"
: undefined
}
className={cn(
dmSans125ClassName(),
"inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",

View file

@ -48,7 +48,7 @@ export function UserProfileMenu({
} catch {
// still navigate if the request fails (offline, etc.)
}
router.push("/login/new")
router.push("/login")
})()
}

View file

@ -26,6 +26,7 @@
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
forwards;
--animate-login-marquee: login-marquee 40s linear infinite;
}
:root {
@ -236,3 +237,319 @@
width: 92%;
}
}
@keyframes login-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.login-page-grid {
display: grid;
min-height: 100dvh;
height: 100dvh;
overflow-x: hidden;
overflow-y: auto;
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
@media (min-width: 1024px) {
.login-page-grid {
height: 100dvh;
overflow: hidden;
grid-template-columns: 65% 35%;
grid-template-rows: minmax(0, 1fr);
}
}
.login-page-mobile-bg {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 0;
}
@media (min-width: 1024px) {
.login-page-mobile-bg {
display: none;
}
}
.login-tools-panel-inner {
flex: 1;
min-height: 0;
}
.login-panel-orb {
position: absolute;
inset: 0;
background:
radial-gradient(
ellipse 130% 100% at 50% 45%,
rgba(70, 155, 255, 0.24) 0%,
rgba(35, 100, 210, 0.14) 38%,
rgba(8, 22, 45, 0.55) 68%,
#030912 100%
),
radial-gradient(
ellipse 90% 70% at 50% 100%,
rgba(50, 130, 240, 0.16) 0%,
transparent 65%
);
}
.login-panel-orb-image {
position: absolute;
inset: -10% -15% -5% -15%;
background-image: url("/onboarding/bg-gradient-0.png");
background-size: cover;
background-position: center 35%;
background-repeat: no-repeat;
opacity: 0.22;
pointer-events: none;
}
.login-panel-orb-image-alt {
position: absolute;
inset: -10% -15% -5% -15%;
background-image: url("/onboarding/bg-gradient-1.png");
background-size: cover;
background-position: center 35%;
background-repeat: no-repeat;
opacity: 0.11;
pointer-events: none;
}
.login-tools-section {
position: relative;
flex-shrink: 0;
padding: 1.5rem 0 2rem;
}
@media (min-width: 1024px) {
.login-tools-section {
padding: 1.75rem 0 2.5rem;
}
}
.login-tools-section::before {
content: "";
position: absolute;
inset-inline: 10%;
top: 0;
height: 1px;
background: linear-gradient(
to right,
transparent,
rgb(255 255 255 / 0.12),
transparent
);
}
.login-tools-marquee {
position: relative;
overflow: hidden;
mask-image: linear-gradient(
to right,
transparent,
black 10%,
black 90%,
transparent
);
}
.login-tools-marquee-track {
display: flex;
width: max-content;
gap: 0.625rem;
animation: login-marquee var(--marquee-duration, 40s) linear infinite;
will-change: transform;
}
.login-tools-marquee-track-reverse {
animation-direction: reverse;
}
.login-tools-marquee:hover .login-tools-marquee-track {
animation-play-state: paused;
}
.login-tools-chip {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.05);
padding: 0.375rem 0.875rem 0.375rem 0.375rem;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
backdrop-filter: blur(8px);
}
.login-tools-chip-icon {
display: flex;
width: 1.625rem;
height: 1.625rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgb(255 255 255 / 0.07);
}
.login-tools-chip-icon svg,
.login-tools-chip-icon img {
width: 0.875rem;
height: 0.875rem;
opacity: 0.92;
}
.login-tools-chip-label {
font-size: 0.75rem;
font-weight: 500;
color: rgb(255 255 255 / 0.82);
white-space: nowrap;
}
.login-tool-node-wrap {
position: absolute;
z-index: 2;
transform: translate(-50%, -50%);
}
.login-tool-node-label {
position: absolute;
top: calc(100% + 0.4rem);
left: 50%;
max-width: 5.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transform: translateX(-50%);
font-size: 0.6875rem;
font-weight: 500;
color: rgb(200 225 255 / 0.95);
text-shadow: 0 1px 8px rgb(0 0 0 / 0.65);
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.login-tool-node-label-visible {
opacity: 1;
}
.login-tool-node {
display: flex;
width: 2.625rem;
height: 2.625rem;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.12);
background: rgb(12 22 40 / 0.82);
backdrop-filter: blur(12px);
box-shadow:
0 1px 0 0 rgb(255 255 255 / 0.1) inset,
0 6px 20px rgb(0 0 0 / 0.22);
transition:
border-color 0.35s ease,
background 0.35s ease,
box-shadow 0.35s ease,
transform 0.35s cubic-bezier(0.34, 1.2, 0.64, 1);
}
@media (min-width: 640px) {
.login-tool-node {
width: 2.875rem;
height: 2.875rem;
border-radius: 0.8125rem;
}
}
.login-tool-node-icon {
width: 1.25rem;
height: 1.25rem;
opacity: 0.88;
transition: opacity 0.35s ease;
}
@media (min-width: 640px) {
.login-tool-node-icon {
width: 1.375rem;
height: 1.375rem;
}
}
.login-tool-node-source {
border-color: rgb(100 180 255 / 0.55);
background: rgb(14 28 50 / 0.92);
box-shadow:
0 0 0 1px rgb(100 180 255 / 0.25),
0 0 24px rgb(75 160 250 / 0.28),
0 1px 0 0 rgb(255 255 255 / 0.1) inset;
transform: scale(1.05);
}
.login-tool-node-source .login-tool-node-icon {
opacity: 1;
}
.login-tool-node-destination {
border-color: rgb(130 210 255 / 0.65);
background: rgb(16 32 58 / 0.95);
box-shadow:
0 0 0 1px rgb(130 210 255 / 0.3),
0 0 32px rgb(100 180 255 / 0.35),
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
transform: scale(1.08);
}
.login-tool-node-destination .login-tool-node-icon {
opacity: 1;
}
.login-context-chip {
display: flex;
width: 2.75rem;
height: 1.625rem;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
border: 1px solid rgb(180 220 255 / 0.35);
background: rgb(12 24 44 / 0.95);
box-shadow:
0 0 16px rgb(100 180 255 / 0.4),
0 1px 0 0 rgb(255 255 255 / 0.12) inset;
}
.login-context-chip-lines {
display: flex;
width: 1.5rem;
flex-direction: column;
gap: 0.2rem;
}
.login-context-chip-lines span {
display: block;
height: 0.125rem;
border-radius: 9999px;
background: rgb(180 220 255 / 0.55);
}
.login-context-chip-lines span:nth-child(1) {
width: 100%;
}
.login-context-chip-lines span:nth-child(2) {
width: 78%;
opacity: 0.75;
}
.login-context-chip-lines span:nth-child(3) {
width: 52%;
opacity: 0.5;
}

View file

@ -295,7 +295,7 @@
},
"packages/memory-graph": {
"name": "@supermemory/memory-graph",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"d3-force": "^3.0.0",
},