Show the orbit animation on the login panel (#1168)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Parthiv 2026-06-27 23:25:37 +05:30 committed by GitHub
parent 3a9310778a
commit f1e2d3b41b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 602 additions and 362 deletions

View file

@ -1,367 +1,8 @@
"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>
)
}
import OrbitMemory from "@/components/orbit-memory"
function LoginPanelBackground() {
return (
@ -373,6 +14,15 @@ function LoginPanelBackground() {
<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 />
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-[1] opacity-[0.05]"
style={{
backgroundImage:
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27200%27%20height%3D%27200%27%3E%3Cfilter%20id%3D%27n%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%270.9%27%20numOctaves%3D%272%27%20stitchTiles%3D%27stitch%27%2F%3E%3CfeColorMatrix%20type%3D%27saturate%27%20values%3D%270%27%2F%3E%3C%2Ffilter%3E%3Crect%20width%3D%27100%25%27%20height%3D%27100%25%27%20filter%3D%27url%28%23n%29%27%2F%3E%3C%2Fsvg%3E")',
backgroundSize: "200px 200px",
}}
/>
</>
)
}
@ -382,8 +32,16 @@ export function LoginToolsPanel() {
<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 className="login-tools-panel-inner relative z-10 flex items-center justify-center px-4 py-8 sm:px-8 lg:px-10">
<div className="h-full" style={{ aspectRatio: "780 / 1024" }}>
{/* overflow:visible lets the core glow bleed and fade into the
panel instead of being hard-clipped at the orbit's box edge;
the panel's own overflow-hidden clips it softly at the edges */}
<OrbitMemory
style={{ background: "transparent", overflow: "visible" }}
grain={false}
/>
</div>
</div>
<p

File diff suppressed because one or more lines are too long