feat(frontend): apply Africa branding and holographic UI improvements

- Migrate UI from picoclaw_v1 with 'Africa' branding (JARVIS -> Africa)
- Add motion library for component animations
- Complete theme overhaul: dark navy (#0a0e17) background, cyan (#00bcff) accents
- 14 new CSS animations: pulse-glow, orb-rotate, scan-line, holographic-pulse
- Add BackgroundOrb and scan line overlay to chat page
- Redesign cockpit with 7 holographic components
- Add cyan holographic styling to memory-graph, agents, skills pages
- Apply glassmorphism effects throughout
This commit is contained in:
anthrodjear 2026-05-08 19:23:41 +03:00
parent 30b0dfdc04
commit 1c6616366a
9 changed files with 1272 additions and 446 deletions

View file

@ -8,7 +8,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PicoClaw</title>
<title>Africa</title>
</head>
<body>

View file

@ -19,6 +19,7 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@tabler/icons-react": "^3.43.0",
"motion": "^12.0.0",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-router": "^1.169.2",

View file

@ -1,5 +1,6 @@
import { useDeferredValue, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { motion } from "motion/react"
import { toast } from "sonner"
import {
@ -20,6 +21,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { IconPlus } from "@tabler/icons-react"
import { AgentCard } from "./agent-card"
import { AgentFormModal } from "./agent-form-modal"
@ -60,7 +62,14 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
if (agentsQuery.isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<p className="text-muted-foreground">Loading agents...</p>
<div className="flex items-center gap-2 text-cyan-100/50">
<motion.div
className="w-2 h-2 rounded-full bg-[#00bcff]"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
Loading agents...
</div>
</div>
)
}
@ -68,7 +77,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
if (agentsQuery.isError) {
return (
<div className="flex h-48 items-center justify-center">
<p className="text-destructive">Failed to load agents. Please try again.</p>
<p className="text-red-400">Failed to load agents. Please try again.</p>
</div>
)
}
@ -107,7 +116,6 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
toast.error(
error instanceof Error ? error.message : "Failed to update agent",
)
// Refresh to revert the UI state
void queryClient.invalidateQueries({ queryKey: ["agents"] })
}
}
@ -121,7 +129,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
}
const mainContent = (
<div className="mx-auto w-full max-w-6xl">
<div className="w-full">
{/* Header Controls */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-md flex-1">
@ -129,27 +137,28 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
placeholder={t("common.search", "Search agents...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background"
className="bg-black/40 border-[#00bcff]/20 text-cyan-100 placeholder:text-cyan-100/30 focus:border-[#00bcff] focus:ring-[#00bcff]/20 backdrop-blur-sm"
/>
</div>
<Button
onClick={() => setIsCreateModalOpen(true)}
className="bg-[#F27D26] hover:bg-[#F27D26]/90 text-black"
className="bg-[#00bcff]/15 hover:bg-[#00bcff]/25 text-[#00bcff] border border-[#00bcff]/30 hover:border-[#00bcff] shadow-[0_0_15px_rgba(0,188,255,0.1)] transition-all"
>
{t("pages.agent.agents.create_agent", "+ Create Agent")}
<IconPlus className="w-4 h-4 mr-1" />
{t("pages.agent.agents.create_agent", "Create Agent")}
</Button>
</div>
{/* Agent Grid */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{filteredAgents.length === 0 ? (
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
<p className="text-muted-foreground">
<div className="col-span-full rounded-xl border border-dashed border-[#00bcff]/15 p-12 text-center bg-black/20 backdrop-blur-sm">
<p className="text-cyan-100/50">
{deferredSearchQuery
? t("pages.agent.agents.no_results", "No agents found")
: t("pages.agent.agents.no_agents", "No agents yet")}
</p>
<p className="mt-2 text-sm text-muted-foreground/60">
<p className="mt-2 text-[11px] text-cyan-100/30">
{deferredSearchQuery
? t("pages.agent.agents.no_results_hint", "Try a different search")
: t("pages.agent.agents.no_agents_hint", "Create an agent to get started")}
@ -198,23 +207,23 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
if (!open) setAgentToDelete(null)
}}
>
<AlertDialogContent>
<AlertDialogContent className="bg-[#0a0e27] border-[#00bcff]/20 text-cyan-100 shadow-[0_0_40px_#00bcff1a]">
<AlertDialogTitle>
{t("pages.agent.agents.confirm_delete", "Delete Agent?")}
</AlertDialogTitle>
<p className="text-muted-foreground">
<p className="text-cyan-100/50">
{t(
"pages.agent.agents.confirm_delete_message",
`Are you sure you want to delete "${agentToDelete?.name}"? This action cannot be undone.`,
)}
</p>
<AlertDialogFooter>
<AlertDialogCancel>
<AlertDialogCancel className="border-[#00bcff]/20 text-cyan-100/60 hover:text-cyan-100 hover:bg-[#00bcff]/5">
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
>
{t("common.delete", "Delete")}
</AlertDialogAction>
@ -234,7 +243,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
}
return (
<div className="bg-background flex h-full flex-col">
<div className="bg-[#0a0e27] flex h-full flex-col">
<PageHeader title={t("navigation.agents", "Agents")} />
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
{mainContent}

View file

@ -1,6 +1,11 @@
import dayjs from "dayjs"
import { useMemo } from "react"
import { IconArrowRight, IconLayoutDashboard, IconUsers, IconBrain, IconFlask } from "@tabler/icons-react"
import { useEffect, useMemo, useState } from "react"
import { motion } from "motion/react"
import {
IconLayoutDashboard, IconUsers, IconBrain, IconFlask,
IconCircleDot, IconActivity, IconCpu, IconDatabase, IconBolt,
IconShield, IconTerminal, IconWifi, IconLock, IconTrendingUp,
} from "@tabler/icons-react"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { Badge } from "@/components/ui/badge"
@ -13,23 +18,439 @@ import { AgentsPage } from "../agents"
import { SkillsPage } from "../skills"
import { ResearchPage } from "../research/research-page"
function reasonLabel(reasonCode?: string) {
switch (reasonCode) {
case "requires_subagent":
return "Requires subagent runtime"
case "requires_skills":
return "Requires skills support"
case "requires_mcp_discovery":
return "Requires MCP discovery"
case "requires_linux":
return "Linux only"
case "requires_serial_platform":
return "Unsupported serial platform"
default:
return reasonCode ?? ""
}
const TAB_CONFIG = [
{ key: "tools" as const, icon: IconLayoutDashboard, label: "Tools" },
{ key: "skills" as const, icon: IconBrain, label: "Skills" },
{ key: "agents" as const, icon: IconUsers, label: "Agents" },
{ key: "research" as const, icon: IconFlask, label: "Research" },
]
/* ─── 3D Holographic Circle Component ─── */
function HolographicCircle() {
const [rotation, setRotation] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setRotation((prev) => (prev + 0.5) % 360)
}, 50)
return () => clearInterval(interval)
}, [])
return (
<div className="relative w-full h-full flex items-center justify-center">
{/* Outer Glow */}
<motion.div
animate={{ opacity: [0.3, 0.6, 0.3], scale: [1, 1.05, 1] }}
transition={{ duration: 3, repeat: Infinity }}
className="absolute inset-0 rounded-full bg-[#00bcff]/20 blur-3xl"
/>
{/* Rotating Rings - 3D Perspective */}
<div
className="absolute inset-0"
style={{ transform: "perspective(1000px) rotateX(65deg)" }}
>
{[0, 1, 2, 3, 4].map((index) => (
<div
key={index}
className="absolute inset-0 rounded-full border-2 border-[#00bcff]/40 shadow-[0_0_20px_#00bcff4d]"
style={{
transform: `rotateZ(${rotation + index * 20}deg) scale(${1 - index * 0.15})`,
}}
/>
))}
{/* Inner Core */}
<div className="absolute inset-[35%] rounded-full bg-gradient-to-br from-[#00bcff]/40 to-blue-500/40 backdrop-blur-sm shadow-[0_0_50px_#00bcff80,inset_0_0_30px_#00bcff4d]" />
{/* Scanning Line */}
<motion.div
className="absolute inset-0"
style={{
background: `conic-gradient(from ${rotation}deg, transparent 0deg, #00bcff99 10deg, transparent 20deg)`,
}}
/>
</div>
{/* Center Display */}
<div className="relative z-10 flex flex-col items-center justify-center" style={{ transform: "perspective(1000px) rotateX(0deg)" }}>
<motion.div
animate={{ scale: [1, 1.1, 1], opacity: [0.8, 1, 0.8] }}
transition={{ duration: 2, repeat: Infinity }}
className="text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-b from-cyan-300 to-blue-500"
>
AI
</motion.div>
<div className="text-[#00bcff] text-[10px] tracking-[0.3em] mt-2 uppercase">Core Online</div>
{/* Data Points around circle */}
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => (
<motion.div
key={angle}
className="absolute w-1.5 h-1.5 rounded-full bg-[#00bcff]"
style={{
left: "50%",
top: "50%",
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-100px)`,
}}
animate={{ opacity: [0.3, 1, 0.3], scale: [0.8, 1.2, 0.8] }}
transition={{ duration: 2, repeat: Infinity, delay: angle / 360 }}
/>
))}
</div>
{/* Holographic Scan Lines */}
<div className="absolute inset-0 overflow-hidden rounded-full pointer-events-none">
<motion.div
className="absolute inset-0 bg-gradient-to-b from-transparent via-[#00bcff]/10 to-transparent"
animate={{ y: ["-100%", "100%"] }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
/>
</div>
</div>
)
}
/* ─── Particle Field Component ─── */
function ParticleField() {
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 3 + 1,
duration: Math.random() * 10 + 10,
delay: Math.random() * 5,
})),
[]
)
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<motion.div
key={p.id}
className="absolute rounded-full bg-[#00bcff]/40"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
width: p.size,
height: p.size,
}}
animate={{
y: [0, -100, 0],
opacity: [0, 1, 0],
scale: [0, 1, 0],
}}
transition={{
duration: p.duration,
repeat: Infinity,
delay: p.delay,
ease: "easeInOut",
}}
/>
))}
{/* Connection Lines */}
<svg className="absolute inset-0 w-full h-full">
{Array.from({ length: 15 }, (_, i) => (
<motion.line
key={i}
x1={`${Math.random() * 100}%`}
y1={`${Math.random() * 100}%`}
x2={`${Math.random() * 100}%`}
y2={`${Math.random() * 100}%`}
stroke="#00bcff1a"
strokeWidth="1"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: [0, 1, 0], opacity: [0, 0.5, 0] }}
transition={{ duration: 3, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</svg>
</div>
)
}
/* ─── Metric Card Component ─── */
function MetricCard({ icon, label, value, trend }: { icon: React.ReactNode; label: string; value: string; trend: string }) {
const isPositive = trend.startsWith("+")
return (
<motion.div
whileHover={{ scale: 1.05 }}
className="relative rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 overflow-hidden shadow-[0_0_20px_#00bcff1a]"
>
{/* Animated Background */}
<motion.div
className="absolute inset-0 bg-gradient-to-br from-[#00bcff]/5 to-blue-500/5"
animate={{ opacity: [0.3, 0.5, 0.3] }}
transition={{ duration: 2, repeat: Infinity }}
/>
{/* Content */}
<div className="relative z-10">
<div className="flex items-center justify-between mb-3">
<div className="p-2 rounded-lg bg-[#00bcff]/10 text-[#00bcff]">{icon}</div>
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Infinity }}
className={`text-xs ${isPositive ? "text-green-400" : "text-red-400"}`}
>
{trend}
</motion.div>
</div>
<div className="text-[10px] text-cyan-300/60 tracking-wider mb-1 uppercase">{label}</div>
<div className="text-2xl font-bold text-cyan-100">{value}</div>
</div>
{/* Scan Line */}
<motion.div
className="absolute inset-x-0 h-px bg-gradient-to-r from-transparent via-[#00bcff]/50 to-transparent"
animate={{ y: [0, 100] }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
{/* Corner Accent */}
<div className="absolute bottom-0 right-0 w-16 h-16 border-r border-b border-[#00bcff]/20" />
</motion.div>
)
}
/* ─── Data Panel Component ─── */
function DataPanel({ title, items }: {
title: string
items: Array<{ label: string; status: string; progress: number }>
}) {
return (
<motion.div
whileHover={{ scale: 1.02 }}
className="rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-5 overflow-hidden relative shadow-[0_0_30px_#00bcff1a,inset_0_0_20px_#00bcff0d]"
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-cyan-300 text-sm tracking-widest font-semibold">{title}</h3>
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="w-1 h-3 bg-[#00bcff]/60 rounded-full"
animate={{ height: ["12px", "20px", "12px"] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</div>
</div>
{/* Items */}
<div className="space-y-3">
{items.map((item, index) => (
<motion.div
key={item.label}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="group"
>
<div className="flex items-center justify-between mb-2">
<span className="text-cyan-100/80 text-xs">{item.label}</span>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
item.status === "ACTIVE" || item.status === "OPTIMAL"
? "bg-green-400/20 text-green-400"
: item.status === "PROCESSING"
? "bg-yellow-400/20 text-yellow-400"
: "bg-[#00bcff]/20 text-[#00bcff]"
}`}
>
{item.status}
</span>
</div>
{/* Progress Bar */}
<div className="relative h-1.5 bg-cyan-950/50 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${item.progress}%` }}
transition={{ duration: 1, delay: index * 0.1 + 0.3 }}
className="absolute inset-y-0 left-0 bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full shadow-[0_0_10px_#00bcff99]"
/>
<motion.div
className="absolute inset-y-0 left-0 right-0 bg-gradient-to-r from-transparent via-white/30 to-transparent"
animate={{ x: ["-100%", "200%"] }}
transition={{ duration: 2, repeat: Infinity, delay: index * 0.3 }}
/>
</div>
</motion.div>
))}
</div>
{/* Decorative Corner */}
<div className="absolute top-0 right-0 w-20 h-20 border-r border-t border-[#00bcff]/20 pointer-events-none" />
</motion.div>
)
}
/* ─── Circular Progress Component ─── */
function CircularProgress({ icon, label, percentage, color = "cyan" }: {
icon: React.ReactNode; label: string; percentage: number; color?: "cyan" | "blue" | "green"
}) {
const radius = 70
const circumference = 2 * Math.PI * radius
const offset = circumference - (percentage / 100) * circumference
const colorMap = {
cyan: { stroke: "#00bcff", glow: "#00bcff99", bg: "#00bcff1a" },
blue: { stroke: "#0080ff", glow: "#0080ff99", bg: "#0080ff1a" },
green: { stroke: "#00ff88", glow: "#00ff8899", bg: "#00ff881a" },
}
const colors = colorMap[color]
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.05 }}
className="relative rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-5 flex flex-col items-center justify-center shadow-[0_0_30px_#00bcff1a]"
>
<div className="relative">
<svg width="140" height="140" className="transform -rotate-90">
<circle cx="70" cy="70" r={radius} stroke={colors.bg} strokeWidth="8" fill="none" />
<motion.circle
cx="70" cy="70" r={radius} stroke={colors.stroke} strokeWidth="8" fill="none"
strokeLinecap="round" strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }} animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.5, ease: "easeOut" }}
style={{ filter: `drop-shadow(0 0 8px ${colors.glow})` }}
/>
<motion.circle
cx="70" cy="70" r={radius} stroke={colors.stroke} strokeWidth="12" fill="none"
strokeLinecap="round" strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }} animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.5, ease: "easeOut" }} opacity="0.3"
style={{ filter: "blur(8px)" }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<motion.div
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="text-[#00bcff] mb-1"
>
{icon}
</motion.div>
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
transition={{ delay: 0.5, type: "spring" }}
className="text-2xl font-bold text-cyan-100"
>
{percentage}%
</motion.div>
</div>
</div>
<div className="mt-3 text-cyan-300 text-xs tracking-[0.2em] uppercase">{label}</div>
<div className="absolute top-3 left-3 right-3 h-px bg-gradient-to-r from-transparent via-[#00bcff]/30 to-transparent" />
<div className="absolute bottom-3 left-3 right-3 h-px bg-gradient-to-r from-transparent via-[#00bcff]/30 to-transparent" />
</motion.div>
)
}
/* ─── System Status Grid Component ─── */
function SystemStatusGrid() {
const statusItems = [
{ icon: <IconWifi className="size-4" />, label: "CONNECTION", value: "STABLE", status: "good" },
{ icon: <IconDatabase className="size-4" />, label: "STORAGE", value: "67% FREE", status: "good" },
{ icon: <IconLock className="size-4" />, label: "SECURITY", value: "ENCRYPTED", status: "good" },
{ icon: <IconTrendingUp className="size-4" />, label: "PERFORMANCE", value: "OPTIMAL", status: "good" },
]
return (
<div className="grid grid-cols-2 gap-3">
{statusItems.map((item, index) => (
<motion.div
key={item.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.05 }}
className="relative rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-3 overflow-hidden group shadow-[0_0_20px_#00bcff1a]"
>
{/* Hover Glow */}
<div className="absolute inset-0 bg-gradient-to-br from-[#00bcff]/0 to-blue-500/0 group-hover:from-[#00bcff]/10 group-hover:to-blue-500/10 transition-all duration-300" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-2">
<div className="p-1.5 rounded bg-[#00bcff]/10 text-[#00bcff]">{item.icon}</div>
<motion.div
className="w-2 h-2 rounded-full bg-green-400 shadow-[0_0_8px_#00ff88cc]"
animate={{ opacity: [0.5, 1, 0.5], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</div>
<div className="text-[9px] text-cyan-300/60 tracking-wider mb-1 uppercase">{item.label}</div>
<div className="text-xs font-semibold text-cyan-100">{item.value}</div>
</div>
{/* Scan Effect */}
<motion.div
className="absolute inset-x-0 h-px bg-gradient-to-r from-transparent via-[#00bcff]/50 to-transparent"
animate={{ y: [0, 60] }}
transition={{ duration: 2, repeat: Infinity, delay: index * 0.3 }}
/>
</motion.div>
))}
</div>
)
}
/* ─── Terminal Output Component ─── */
function TerminalOutput() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="flex-1 rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 overflow-hidden shadow-[0_0_30px_#00bcff1a,inset_0_0_20px_#00bcff0d]"
>
<div className="flex items-center gap-2 mb-3">
<IconTerminal className="size-4 text-[#00bcff]" />
<span className="text-cyan-300 text-xs tracking-wider">TERMINAL</span>
</div>
<div className="space-y-1 font-mono text-xs">
<motion.div
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.7 }}
className="text-[#00bcff]/60"
>
&gt; Initializing core systems...
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.8 }}
className="text-green-400/60"
>
&gt; All systems operational
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.9 }}
className="text-[#00bcff]/60"
>
&gt; Awaiting commands...
</motion.div>
<motion.div
animate={{ opacity: [0, 1, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
className="text-[#00bcff] inline-block"
>
|
</motion.div>
</div>
</motion.div>
)
}
/*
MAIN COCKPIT PAGE
*/
export function CockpitPage() {
const { activeSessionId } = usePicoChat()
const {
@ -48,141 +469,291 @@ export function CockpitPage() {
)
return (
<div className="flex h-full flex-col overflow-hidden bg-[#050505] text-[#F2F2F2] selection:bg-[#F27D26] selection:text-black font-sans relative">
{/* Ghost Background Typography */}
<div className="absolute inset-0 overflow-hidden pointer-events-none select-none opacity-[0.05]">
<span className="absolute -left-20 -top-10 text-[35vw] font-black leading-none uppercase">COCKPIT</span>
<span className="absolute -right-20 -bottom-20 text-[25vw] font-black leading-none uppercase">SYSTEM</span>
</div>
<div className="relative size-full bg-[#0a0e27] overflow-hidden text-[#e0f2fe] font-sans">
{/* ── 3D Perspective Grid Background ── */}
<div className="absolute inset-0 bg-[linear-gradient(#00bcff08_1px,transparent_1px),linear-gradient(90deg,#00bcff08_1px,transparent_1px)] bg-[size:50px_50px] [transform:perspective(1000px)_rotateX(60deg)] origin-center opacity-40" />
{/* Header */}
<header className="flex justify-between items-start border-b border-white/10 p-6 md:px-12 md:py-8 z-10">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Terminal Status</span>
<span className="text-xs opacity-60 font-mono tracking-tighter">CONNECTED / {dayjs().format('DD.MM.YYYY')}</span>
</div>
<div className="flex flex-col gap-1 text-right">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Runtime Epoch</span>
<span className="text-xs opacity-60 font-mono tracking-tighter">{dayjs().format('HH:mm')} GMT+1</span>
</div>
</header>
{/* ── Particle Field ── */}
<ParticleField />
<div className="flex-1 overflow-auto px-6 py-6 md:px-12 md:py-10 z-10">
<div className="mx-auto grid w-full max-w-[1600px] gap-12 xl:grid-cols-[1fr_380px]">
{/* ── Scanline Effect ── */}
<div className="absolute inset-0 bg-[linear-gradient(transparent_50%,#00bcff08_50%)] bg-[size:100%_4px] pointer-events-none animate-[scan_8s_linear_infinite]" />
{/* Main Workspace */}
<div className="space-y-16">
{/* Hero Section */}
<div className="relative">
<h1 className="text-[6vw] leading-[0.85] font-black tracking-[-0.07em] uppercase m-0 p-0 text-[#F2F2F2]">
AGENT INTERFACE
</h1>
<p className="mt-8 text-xl font-light max-w-xl opacity-60 leading-relaxed border-l-2 border-[#F27D26] pl-6">
Active control node for autonomous agents. Managing tool surfaces, memory networks.
</p>
{/* ── Corner Accents ── */}
<div className="absolute top-0 left-0 w-32 h-32 border-l-2 border-t-2 border-[#00bcff]/30 pointer-events-none" />
<div className="absolute top-0 right-0 w-32 h-32 border-r-2 border-t-2 border-[#00bcff]/30 pointer-events-none" />
<div className="absolute bottom-0 left-0 w-32 h-32 border-l-2 border-b-2 border-[#00bcff]/30 pointer-events-none" />
<div className="absolute bottom-0 right-0 w-32 h-32 border-r-2 border-b-2 border-[#00bcff]/30 pointer-events-none" />
{/* ── Main Content ── */}
<div className="relative z-10 size-full p-6 flex flex-col">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-6"
>
<div>
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-400 to-cyan-300 tracking-wider">
AFRICA
</h1>
<p className="text-cyan-300/60 text-xs mt-1 tracking-[0.3em]">
ADVANCED FRAMEWORK FOR INTELLIGENT SYSTEM
</p>
</div>
<div className="flex items-center gap-4">
<div className="px-5 py-2.5 rounded-full border border-[#00bcff]/30 bg-[#00bcff]/5 backdrop-blur-sm shadow-[0_0_20px_#00bcff4d]">
<span className="text-cyan-300 text-xs tracking-wider">SYSTEMS ONLINE</span>
</div>
<div className="flex items-center gap-4 border-b border-white/10 pb-4">
<button
onClick={() => setActiveTab("tools")}
className={cn(
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
activeTab === "tools" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
)}
>
<IconLayoutDashboard className="size-4" />
Tools
</button>
<button
onClick={() => setActiveTab("skills")}
className={cn(
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
activeTab === "skills" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
)}
>
<IconBrain className="size-4" />
Skills
</button>
<button
onClick={() => setActiveTab("agents")}
className={cn(
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
activeTab === "agents" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
)}
>
<IconUsers className="size-4" />
Agents
</button>
<button
onClick={() => setActiveTab("research")}
className={cn(
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
activeTab === "research" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
)}
>
<IconFlask className="size-4" />
Research
</button>
<div className="text-[10px] font-mono text-cyan-300/40">
{dayjs().format("DD.MM.YYYY")} / {dayjs().format("HH:mm")} UTC
</div>
</div>
</motion.div>
{activeTab === "skills" && <SkillsPage embedded />}
{/* Tab Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="flex items-center gap-1 p-1 rounded-xl border border-[#00bcff]/20 bg-black/30 backdrop-blur-sm shadow-[0_0_15px_#00bcff1a] mb-6"
>
{TAB_CONFIG.map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={cn(
"flex items-center gap-2 px-5 py-2.5 rounded-lg text-[11px] uppercase tracking-widest font-bold transition-all duration-300",
activeTab === key
? "bg-[#00bcff]/15 text-[#00bcff] shadow-[0_0_15px_rgba(0,188,255,0.15)] border border-[#00bcff]/30"
: "text-[#94a3b8] hover:text-[#e0f2fe] hover:bg-[#00bcff]/5 border border-transparent"
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</motion.div>
{activeTab === "agents" && <AgentsPage embedded />}
{/* ── Main Grid Layout ── */}
<div className="flex-1 grid grid-cols-12 gap-5 overflow-hidden">
{/* Left Column - Holographic Display + System Status */}
{activeTab === "tools" && (
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="col-span-4 flex flex-col gap-5 overflow-y-auto"
>
{/* Main Holographic Circle */}
<div className="relative aspect-square max-h-[320px]">
<HolographicCircle />
</div>
{activeTab === "research" && <ResearchPage />}
{/* System Status Cards */}
<SystemStatusGrid />
</motion.div>
)}
{/* Center Column - Main Content Area */}
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className={cn(
"flex flex-col gap-5 overflow-y-auto",
activeTab === "tools" ? "col-span-5" : "col-span-12"
)}
>
{/* Tools Tab Content */}
{activeTab === "tools" && (
<section className="grid gap-12">
{/* Tool Grid */}
<div className="space-y-8">
<div className="flex items-end justify-between border-b border-white/10 pb-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Active Modules</span>
<h2 className="text-4xl font-black uppercase tracking-tight">Tool Grid</h2>
<>
{/* Top Metrics */}
<div className="grid grid-cols-2 gap-4">
<MetricCard
icon={<IconCpu className="size-5" />}
label="CPU LOAD"
value="47%"
trend="+2.3%"
/>
<MetricCard
icon={<IconDatabase className="size-5" />}
label="MEMORY"
value="8.2 GB"
trend="-1.1%"
/>
<MetricCard
icon={<IconBolt className="size-5" />}
label="POWER"
value="89%"
trend="+5.2%"
/>
<MetricCard
icon={<IconActivity className="size-5" />}
label="NETWORK"
value="12.4 MB/s"
trend="+8.7%"
/>
</div>
{/* Active Protocols / Tool Status */}
<DataPanel
title="ACTIVE PROTOCOLS"
items={groupedTools.flatMap(([, items]) =>
items.slice(0, 4).map((tool) => ({
label: tool.name,
status: tool.status === "enabled" ? "ACTIVE" : "STANDBY",
progress: tool.status === "enabled" ? 94 : 30,
}))
)}
/>
{/* Memory Network */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconCircleDot className="w-3.5 h-3.5 text-[#00bcff]" />
<span className="text-cyan-300 text-sm tracking-widest font-semibold">MEMORY NETWORK</span>
</div>
</div>
<div className="flex items-center gap-6">
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">{filteredToolCount} Visible</span>
<a href="/agent/tools" className="text-xs font-bold uppercase tracking-widest hover:text-[#F27D26] transition-colors border-b border-white/20 pb-1">
Configuration
</a>
<div className="rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-5 overflow-hidden shadow-[0_0_30px_#00bcff1a,inset_0_0_20px_#00bcff0d]">
<MemoryGraph
nodes={sessionMemoryGraph?.nodes ?? []}
edges={sessionMemoryGraph?.edges ?? []}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{groupedTools.flatMap(([, items]) =>
items.map((tool) => (
<div
key={tool.name}
className="group relative border border-white/10 bg-[#0A0A0A] p-4 hover:border-[#F27D26]/50 transition-all duration-300"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="space-y-1 flex-1 min-w-0">
<h4 className="font-bold text-sm tracking-tight uppercase truncate group-hover:text-[#F27D26] transition-colors">
{tool.name}
</h4>
<Badge
className={cn(
"rounded-none text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5",
tool.status === "enabled" ? "bg-[#F27D26] text-black" : "bg-white/10 text-white/40"
)}
>
{tool.status}
</Badge>
{/* Tool Grid - Compact */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-cyan-300 text-sm tracking-widest font-semibold">TOOL REGISTRY</span>
<span className="text-[9px] uppercase tracking-widest text-[#94a3b8] font-bold">{filteredToolCount} Active</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
{groupedTools.flatMap(([, items]) =>
items.map((tool) => (
<div
key={tool.name}
className="group relative rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-3 overflow-hidden shadow-[0_0_20px_#00bcff1a] hover:border-[#00bcff]/40 transition-all duration-300"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-bold text-[11px] tracking-tight uppercase text-cyan-100 group-hover:text-[#00bcff] transition-colors truncate">
{tool.name}
</h4>
</div>
<Switch
checked={tool.status !== "disabled"}
disabled={pendingToolName === tool.name}
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
className="data-[state=checked]:bg-[#00bcff] scale-75"
/>
</div>
<Switch
checked={tool.status !== "disabled"}
disabled={pendingToolName === tool.name}
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
<p className="text-[10px] text-cyan-100/50 leading-relaxed line-clamp-2">
{tool.description}
</p>
<Badge
className={cn(
"rounded-md text-[7px] uppercase tracking-widest font-black px-1.5 py-0.5 mt-2",
tool.status === "enabled"
? "bg-[#00bcff]/20 text-[#00bcff] border border-[#00bcff]/30"
: "bg-white/5 text-[#94a3b8] border border-white/10"
)}
>
{tool.status}
</Badge>
{/* Scan Line */}
<motion.div
className="absolute inset-x-0 h-px bg-gradient-to-r from-transparent via-[#00bcff]/30 to-transparent"
animate={{ y: [0, 80] }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
</div>
<p className="text-xs text-[#F2F2F2]/60 leading-relaxed font-light mb-4 line-clamp-2">
{tool.description}
</p>
<div className="flex items-center justify-between text-[8px] font-mono uppercase tracking-widest text-white/30 pt-3 border-t border-white/5">
<span className="truncate">{tool.config_key}</span>
{(tool as any).reason_code && (
<span className="text-[#F27D26]/60">{reasonLabel((tool as any).reason_code)}</span>
)}
))
)}
</div>
</div>
</>
)}
{/* Skills Tab */}
{activeTab === "skills" && <SkillsPage embedded />}
{/* Agents Tab */}
{activeTab === "agents" && <AgentsPage embedded />}
{/* Research Tab */}
{activeTab === "research" && <ResearchPage />}
</motion.div>
{/* Right Column - Circular Stats + Terminal */}
{activeTab === "tools" && (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="col-span-3 flex flex-col gap-5 overflow-y-auto"
>
<CircularProgress
icon={<IconShield className="size-7" />}
label="DEFENSE"
percentage={92}
color="cyan"
/>
<CircularProgress
icon={<IconCpu className="size-7" />}
label="UPTIME"
percentage={99}
color="blue"
/>
<CircularProgress
icon={<IconActivity className="size-7" />}
label="CONNECTIVITY"
percentage={87}
color="cyan"
/>
{/* Subagent Manifest */}
<div className="rounded-2xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 shadow-[0_0_30px_#00bcff1a]">
<div className="flex items-center gap-2 mb-3">
<IconCircleDot className="size-3.5 text-[#00bcff]" />
<span className="text-cyan-300 text-[10px] tracking-[0.2em] uppercase font-semibold">Subagents</span>
</div>
<div className="space-y-2">
{sessionSubagents === null ? (
<div className="text-[10px] text-cyan-100/50 flex items-center gap-2">
<motion.div
className="w-2 h-2 rounded-full bg-[#00bcff]"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
Loading...
</div>
) : sessionSubagents.length === 0 ? (
<div className="text-[10px] text-cyan-100/40">No subagents active</div>
) : (
sessionSubagents.map((task) => (
<div key={task.id} className="group rounded-lg border border-[#00bcff]/15 bg-black/30 p-2.5 hover:border-[#00bcff]/30 transition-all cursor-pointer">
<div className="flex justify-between items-center mb-1">
<span className="font-bold text-[10px] uppercase tracking-tight text-cyan-100 group-hover:text-[#00bcff] transition-colors truncate">
{task.label || task.id}
</span>
<span className={cn(
"text-[7px] uppercase font-mono px-1.5 py-0.5 rounded-md",
task.status === "completed"
? "bg-green-400/15 text-green-400"
: task.status === "failed"
? "bg-red-400/15 text-red-400"
: "bg-[#00bcff]/15 text-[#00bcff]"
)}>
{task.status}
</span>
</div>
<div className="text-[8px] font-mono text-cyan-100/30">
{dayjs(task.created).format("HH:mm:ss")}
</div>
</div>
))
@ -190,79 +761,12 @@ export function CockpitPage() {
</div>
</div>
{/* Memory Network */}
<div className="space-y-8">
<div className="flex items-end justify-between border-b border-white/10 pb-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Relational Map</span>
<h2 className="text-4xl font-black uppercase tracking-tight">Memory Network</h2>
</div>
</div>
<div className="p-8 border border-white/10 bg-[#0A0A0A] relative overflow-hidden">
<MemoryGraph
nodes={sessionMemoryGraph?.nodes ?? []}
edges={sessionMemoryGraph?.edges ?? []}
/>
</div>
</div>
</section>
)}
</div>
{/* Right Sidebar */}
<aside className="space-y-12">
{/* Subagents */}
<div className="space-y-8">
<div className="border-b border-white/10 pb-2">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
</div>
<div className="space-y-4">
{sessionSubagents === null ? (
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
Loading subagents...
</div>
) : sessionSubagents.length === 0 ? (
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
No subagents have been created in this session yet.
</div>
) : (
sessionSubagents.map((task) => (
<div key={task.id} className="group border border-white/10 p-5 bg-[#0A0A0A] hover:border-white/30 transition-all">
<div className="flex justify-between items-start mb-2">
<span className="font-bold text-sm uppercase tracking-tight">{task.label || task.id}</span>
<span className={cn(
"text-[9px] uppercase font-mono px-1.5 py-0.5",
task.status === "completed" ? "bg-green-500/20 text-green-400" : "bg-[#F27D26]/20 text-[#F27D26]"
)}>
{task.status}
</span>
</div>
<div className="text-[9px] font-mono text-white/30 truncate">
{dayjs(task.created).format("HH:mm:ss [UTC]")}
</div>
</div>
))
)}
</div>
</div>
</aside>
{/* Terminal Output */}
<TerminalOutput />
</motion.div>
)}
</div>
</div>
{/* Footer */}
<footer className="h-20 border-t border-white/10 flex items-center justify-between px-6 md:px-12 z-10 bg-[#050505]">
<nav className="flex gap-10 text-[10px] font-bold uppercase tracking-[0.3em]">
<a href="#" className="hover:text-[#F27D26] transition-colors">Portfolio</a>
<a href="#" className="hover:text-[#F27D26] transition-colors">Documentation</a>
<a href="#" className="hover:text-[#F27D26] transition-colors">Gateway</a>
</nav>
<div className="flex items-center gap-6">
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">System Ref: BOLD-UX-01</span>
<div className="h-10 w-10 rounded-full border border-white/10 flex items-center justify-center hover:bg-white hover:text-black cursor-pointer transition-all">
<IconArrowRight size={16} />
</div>
</div>
</footer>
</div>
)
}
}

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from "react"
import { motion } from "motion/react"
import type {
PicoMemoryGraphEdge,
@ -28,11 +29,11 @@ const GROUP_COLUMNS: Record<string, number> = {
}
function groupColor(group: string) {
if (group === "memory") return "#72f0a0"
if (group.startsWith("daily-")) return "#4dd0e1"
if (group === "tool") return "#f8d66d"
if (group === "media") return "#f395d6"
return "#90e89f"
if (group === "memory") return "#00bcff"
if (group.startsWith("daily-")) return "#22d3ee"
if (group === "tool") return "#38bdf8"
if (group === "media") return "#7dd3fc"
return "#06b6d4"
}
function computeLayout(nodes: PicoMemoryGraphNode[]): PositionedNode[] {
@ -76,69 +77,133 @@ export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
positionedNodes.find((node) => node.id === selectedId) ?? positionedNodes[0]
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="overflow-hidden rounded-xl border border-[#173621] bg-[#041008]">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_240px]">
<div className="overflow-hidden rounded-xl border border-[#00bcff]/20 bg-[#060e1a] relative">
{/* Holographic scan line overlay */}
<motion.div
className="absolute inset-0 z-10 pointer-events-none bg-gradient-to-b from-transparent via-[#00bcff]/5 to-transparent"
animate={{ y: ["-100%", "100%"] }}
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
/>
<svg
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
className="h-[420px] w-full"
className="h-[380px] w-full"
role="img"
aria-label="PicoClaw memory graph"
>
<defs>
<linearGradient id="memoryGrid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#0c2014" />
<stop offset="100%" stopColor="#050d08" />
{/* 3D Grid Gradient */}
<linearGradient id="jarvisGrid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#0a1628" />
<stop offset="50%" stopColor="#060e1a" />
<stop offset="100%" stopColor="#0a1628" />
</linearGradient>
{/* Glow filter for edges */}
<filter id="jarvisGlow">
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Stronger glow for active nodes */}
<filter id="jarvisGlowStrong">
<feGaussianBlur stdDeviation="8" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Holographic radial gradient for nodes */}
<radialGradient id="holoNodeGrad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#00bcff" stopOpacity="0.3" />
<stop offset="70%" stopColor="#00bcff" stopOpacity="0.1" />
<stop offset="100%" stopColor="#00bcff" stopOpacity="0.02" />
</radialGradient>
</defs>
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#memoryGrid)" />
{Array.from({ length: 10 }).map((_, index) => (
{/* Background */}
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#jarvisGrid)" />
{/* 3D Perspective Grid Lines */}
{Array.from({ length: 20 }).map((_, index) => (
<line
key={`v-${index}`}
x1={(VIEWBOX_WIDTH / 10) * index}
x1={(VIEWBOX_WIDTH / 20) * index}
y1="0"
x2={(VIEWBOX_WIDTH / 10) * index}
x2={(VIEWBOX_WIDTH / 20) * index}
y2={VIEWBOX_HEIGHT}
stroke="#0d2817"
stroke="rgba(0, 188, 255, 0.03)"
strokeWidth="1"
/>
))}
{Array.from({ length: 8 }).map((_, index) => (
{Array.from({ length: 12 }).map((_, index) => (
<line
key={`h-${index}`}
x1="0"
y1={(VIEWBOX_HEIGHT / 8) * index}
y1={(VIEWBOX_HEIGHT / 12) * index}
x2={VIEWBOX_WIDTH}
y2={(VIEWBOX_HEIGHT / 8) * index}
stroke="#0d2817"
y2={(VIEWBOX_HEIGHT / 12) * index}
stroke="rgba(0, 188, 255, 0.03)"
strokeWidth="1"
/>
))}
{/* Edges with holographic glow */}
{edges.map((edge) => {
const source = nodeMap.get(edge.source)
const target = nodeMap.get(edge.target)
if (!source || !target) {
return null
}
if (!source || !target) return null
const active =
selectedId != null &&
(selectedId === edge.source || selectedId === edge.target)
return (
<line
key={`${edge.source}-${edge.target}-${edge.kind}`}
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={active ? "#9dfdbb" : "#1f5c34"}
strokeOpacity={active ? 0.9 : 0.45}
strokeWidth={active ? 2.5 : 1.4}
/>
<g key={`${edge.source}-${edge.target}-${edge.kind}`}>
{/* Glow layer */}
{active && (
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#00bcff"
strokeOpacity={0.3}
strokeWidth={6}
filter="url(#jarvisGlowStrong)"
/>
)}
{/* Main edge */}
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={active ? "#00bcff" : "rgba(0, 188, 255, 0.12)"}
strokeOpacity={active ? 0.9 : 0.5}
strokeWidth={active ? 2 : 1}
filter={active ? "url(#jarvisGlow)" : undefined}
/>
{/* Animated pulse along edge when active */}
{active && (
<circle r="3" fill="#00bcff" filter="url(#jarvisGlow)">
<animateMotion
dur="2s"
repeatCount="indefinite"
path={`M${source.x},${source.y} L${target.x},${target.y}`}
/>
</circle>
)}
</g>
)
})}
{/* Nodes with 3D holographic style */}
{positionedNodes.map((node) => {
const active = node.id === selectedNode?.id
const color = groupColor(node.group)
@ -152,29 +217,82 @@ export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
onClick={() => setSelectedId(node.id)}
className="cursor-pointer"
>
{/* Outer holographic ring - pulsing */}
<circle
cx={node.x}
cy={node.y}
r={radius + 18}
fill="transparent"
stroke={active ? color : "transparent"}
strokeWidth="0.5"
strokeOpacity={active ? 0.2 : 0}
strokeDasharray="4 4"
>
{active && (
<animate
attributeName="stroke-dashoffset"
from="0"
to="8"
dur="1s"
repeatCount="indefinite"
/>
)}
</circle>
{/* Glow halo */}
<circle
cx={node.x}
cy={node.y}
r={radius + 10}
fill={active ? `${color}22` : "transparent"}
fill={active ? `${color}15` : "transparent"}
/>
{/* Holographic field */}
{active && (
<circle
cx={node.x}
cy={node.y}
r={radius + 6}
fill="url(#holoNodeGrad)"
filter="url(#jarvisGlow)"
/>
)}
{/* Main node circle */}
<circle
cx={node.x}
cy={node.y}
r={radius}
fill="#07110a"
fill="#0a1628"
stroke={color}
strokeWidth={active ? 3 : 2}
strokeWidth={active ? 2.5 : 1.5}
filter={active ? "url(#jarvisGlow)" : undefined}
/>
{/* Inner glow ring */}
{active && (
<circle
cx={node.x}
cy={node.y}
r={radius - 4}
fill="transparent"
stroke={color}
strokeWidth="0.5"
strokeOpacity="0.4"
/>
)}
{/* Label */}
<text
x={node.x}
y={node.y + 4}
textAnchor="middle"
fill={color}
fontSize={node.kind === "root" ? "12" : "10"}
fill={active ? "#e0f2fe" : color}
fontSize={node.kind === "root" ? "11" : "9"}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontWeight={active ? "600" : "400"}
>
{node.kind === "root" ? node.label : node.label.slice(0, 18)}
{node.kind === "root" ? node.label : node.label.slice(0, 16)}
</text>
</g>
)
@ -182,50 +300,51 @@ export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
</svg>
</div>
<div className="space-y-3 rounded-xl border border-[#173621] bg-[#050d08] p-4">
{/* Node Detail Panel */}
<div className="space-y-3 rounded-xl border border-[#00bcff]/20 bg-black/40 backdrop-blur-sm p-4 shadow-[0_0_20px_#00bcff1a]">
{selectedNode ? (
<>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-sm uppercase tracking-[0.22em] text-[#95d7a5]">
<p className="font-mono text-[9px] uppercase tracking-[0.25em] text-[#00bcff]">
Node Focus
</p>
<h3 className="mt-2 text-sm font-semibold text-[#effff3]">
<h3 className="mt-2 text-sm font-semibold text-cyan-100">
{selectedNode.label}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"border-[#2a6b3d] text-[#9fd8ae]",
selectedNode.kind === "tool" && "border-[#8c7931] text-[#f8d66d]",
"border-[#00bcff]/30 text-[#00bcff] text-[9px]",
selectedNode.kind === "tool" && "border-[#38bdf8]/30 text-[#38bdf8]",
)}
>
{selectedNode.kind}
</Badge>
</div>
<p className="text-sm leading-relaxed text-[#9cc8a8]">
<p className="text-[11px] leading-relaxed text-cyan-100/50">
{selectedNode.preview || "No extra preview for this node yet."}
</p>
<div className="grid gap-2 text-xs text-[#74a282]">
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<div className="grid gap-2 text-[11px] text-cyan-100/50">
<div className="flex items-center justify-between rounded-lg border border-[#00bcff]/10 bg-[#00bcff]/3 px-3 py-2">
<span>Cluster</span>
<span className="font-mono uppercase text-[#d7f9df]">
<span className="font-mono uppercase text-cyan-100">
{selectedNode.group}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<div className="flex items-center justify-between rounded-lg border border-[#00bcff]/10 bg-[#00bcff]/3 px-3 py-2">
<span>Weight</span>
<span className="font-mono text-[#d7f9df]">
<span className="font-mono text-cyan-100">
{selectedNode.weight ?? 1}
</span>
</div>
</div>
</>
) : (
<p className="text-sm text-[#8bb39a]">No graph data available.</p>
<p className="text-[11px] text-cyan-100/40">No graph data available.</p>
)}
</div>
</div>
)
}
}

View file

@ -1,5 +1,6 @@
import { useDeferredValue, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { motion } from "motion/react"
import type { SkillSupportItem } from "@/api/skills"
import { useCockpitSkills } from "@/hooks/use-cockpit-skills"
import { PageHeader } from "@/components/page-header"
@ -56,7 +57,14 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<p className="text-muted-foreground">Loading skills...</p>
<div className="flex items-center gap-2 text-cyan-100/50">
<motion.div
className="w-2 h-2 rounded-full bg-[#00bcff]"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
Loading skills...
</div>
</div>
)
}
@ -64,33 +72,38 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
if (isError) {
return (
<div className="flex h-48 items-center justify-center">
<p className="text-destructive">Failed to load skills. Please try again.</p>
<p className="text-red-400">Failed to load skills. Please try again.</p>
</div>
)
}
const mainContent = (
<div className="mx-auto w-full max-w-6xl">
<div className="w-full">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-md flex-1">
<Input
placeholder={t("common.search", "Search skills...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background"
/>
<div className="relative">
<Input
placeholder={t("common.search", "Search skills...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-black/40 border-[#00bcff]/20 text-cyan-100 placeholder:text-cyan-100/30 focus:border-[#00bcff] focus:ring-[#00bcff]/20 backdrop-blur-sm"
/>
</div>
</div>
<div className="text-[9px] uppercase tracking-widest text-cyan-100/40 font-bold">
{filteredSkills.length} Skills Loaded
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{filteredSkills.length === 0 ? (
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
<p className="text-muted-foreground">
<div className="col-span-full rounded-xl border border-dashed border-[#00bcff]/15 p-12 text-center bg-black/20 backdrop-blur-sm">
<p className="text-cyan-100/50">
{deferredSearchQuery
? t("pages.agent.skills.no_results", "No skills found")
: t("pages.agent.skills.no_skills", "No skills installed")}
</p>
<p className="mt-2 text-sm text-muted-foreground/60">
<p className="mt-2 text-[11px] text-cyan-100/30">
{deferredSearchQuery
? t("pages.agent.skills.no_results_hint", "Try a different search")
: t("pages.agent.skills.no_skills_hint", "Install skills via the CLI or import them")}
@ -114,23 +127,23 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
open={skillToDelete !== null}
onOpenChange={(open) => { if (!open) setSkillToDelete(null) }}
>
<AlertDialogContent>
<AlertDialogContent className="bg-[#0a0e27] border-[#00bcff]/20 text-cyan-100 shadow-[0_0_40px_#00bcff1a]">
<AlertDialogTitle>
{t("pages.agent.skills.confirm_delete", "Delete Skill?")}
</AlertDialogTitle>
<p className="text-muted-foreground">
<p className="text-cyan-100/50">
{t(
"pages.agent.skills.confirm_delete_message",
`Are you sure you want to delete "${skillToDelete?.name}"? This action cannot be undone.`,
)}
</p>
<AlertDialogFooter>
<AlertDialogCancel>
<AlertDialogCancel className="border-[#00bcff]/20 text-cyan-100/60 hover:text-cyan-100 hover:bg-[#00bcff]/5">
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
>
{t("common.delete", "Delete")}
</AlertDialogAction>
@ -149,7 +162,7 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
}
return (
<div className="bg-background flex h-full flex-col">
<div className="bg-[#0a0e27] flex h-full flex-col">
<PageHeader title={t("navigation.skills", "Skills")} />
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
{mainContent}
@ -157,4 +170,4 @@ export function SkillsPage({ embedded = false }: SkillsPageProps) {
{modals}
</div>
)
}
}

View file

@ -53,6 +53,30 @@ function readFileAsDataUrl(file: File): Promise<string> {
})
}
/** Background Jarvis Orb — large semi-transparent orb behind the chat messages */
function BackgroundOrb() {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{/* Main orb glow - centered in the viewport */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{/* Outermost ring - very subtle */}
<svg className="absolute -left-48 -top-48 h-96 w-96 opacity-[0.07]" viewBox="0 0 400 400">
<circle cx="200" cy="200" r="190" fill="none" stroke="rgba(0, 212, 255, 0.3)" strokeWidth="0.5" strokeDasharray="12 8" style={{ animation: "orb-rotate 20s linear infinite", transformOrigin: "center" }} />
<circle cx="200" cy="200" r="165" fill="none" stroke="rgba(0, 212, 255, 0.2)" strokeWidth="0.8" strokeDasharray="4 12" style={{ animation: "orb-rotate-reverse 14s linear infinite", transformOrigin: "center" }} />
<circle cx="200" cy="200" r="140" fill="none" stroke="rgba(0, 212, 255, 0.35)" strokeWidth="0.5" style={{ animation: "ring-pulse 4s ease-in-out infinite" }} />
</svg>
{/* Core glow */}
<div className="animate-breathe flex h-28 w-28 items-center justify-center rounded-full bg-[rgba(0,212,255,0.04)] shadow-[0_0_60px_rgba(0,212,255,0.12),0_0_120px_rgba(0,212,255,0.06)]">
<div className="h-12 w-12 rounded-full bg-[rgba(0,212,255,0.08)] shadow-[0_0_30px_rgba(0,212,255,0.25),0_0_60px_rgba(0,212,255,0.1)]" />
</div>
</div>
{/* Ambient side glows */}
<div className="absolute -left-32 top-1/3 h-64 w-64 rounded-full bg-[#00d4ff]/[0.02] blur-[80px]" />
<div className="absolute -right-32 bottom-1/4 h-56 w-56 rounded-full bg-[#0ea5e9]/[0.03] blur-[70px]" />
</div>
)
}
function resolveChatInputDisabledReason({
hasDefaultModel,
connectionState,
@ -62,46 +86,16 @@ function resolveChatInputDisabledReason({
connectionState: ConnectionState
gatewayState: GatewayState
}): ChatInputDisabledReason | null {
if (gatewayState === "unknown") {
return "gatewayUnknown"
}
if (gatewayState === "starting") {
return "gatewayStarting"
}
if (gatewayState === "restarting") {
return "gatewayRestarting"
}
if (gatewayState === "stopping") {
return "gatewayStopping"
}
if (gatewayState === "stopped") {
return "gatewayStopped"
}
if (gatewayState === "error") {
return "gatewayError"
}
if (connectionState === "connecting") {
return "websocketConnecting"
}
if (connectionState === "error") {
return "websocketError"
}
if (connectionState === "disconnected") {
return "websocketDisconnected"
}
if (!hasDefaultModel) {
return "noDefaultModel"
}
if (gatewayState === "unknown") return "gatewayUnknown"
if (gatewayState === "starting") return "gatewayStarting"
if (gatewayState === "restarting") return "gatewayRestarting"
if (gatewayState === "stopping") return "gatewayStopping"
if (gatewayState === "stopped") return "gatewayStopped"
if (gatewayState === "error") return "gatewayError"
if (connectionState === "connecting") return "websocketConnecting"
if (connectionState === "error") return "websocketError"
if (connectionState === "disconnected") return "websocketDisconnected"
if (!hasDefaultModel) return "noDefaultModel"
return null
}
@ -199,19 +193,15 @@ export function ChatPage() {
// Check for permission requests in messages
useEffect(() => {
if (permissionRequest) return // Already showing a permission prompt
// Check the last few messages for request_permission tool calls
if (permissionRequest) return
for (let i = messages.length - 1; i >= Math.max(0, messages.length - 5); i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (!msg.toolCalls) continue
const permCall = msg.toolCalls.find(
(tc) => tc.function?.name === "request_permission"
)
if (!permCall || !permCall.function) continue
try {
const args = JSON.parse(permCall.function.arguments ?? "{}")
if (args.path && args.command) {
@ -222,25 +212,16 @@ export function ChatPage() {
})
return
}
} catch {
// Ignore parse errors
}
} catch { /* ignore */ }
}
}, [messages, permissionRequest])
const handlePermissionGranted = () => {
setPermissionRequest(null)
}
const handlePermissionDenied = () => {
setPermissionRequest(null)
}
const handlePermissionGranted = () => setPermissionRequest(null)
const handlePermissionDenied = () => setPermissionRequest(null)
const handleAddImages = () => {
if (!canInput) return
fileInputRef.current?.click()
}
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index))
}
@ -298,11 +279,11 @@ export function ChatPage() {
canInput && (Boolean(input.trim()) || attachments.length > 0)
return (
<div className="bg-background/95 flex h-full flex-col">
<div className="flex h-full flex-col bg-[#0a0e17]">
<PageHeader
title={t("navigation.chat")}
className={`transition-shadow ${
hasScrolled ? "shadow-xs" : "shadow-none"
hasScrolled ? "shadow-[0_1px_0_rgba(0,212,255,0.1)]" : "shadow-none"
}`}
titleExtra={
hasAvailableModels && (
@ -316,8 +297,8 @@ export function ChatPage() {
)
}
>
<div className="border-border/60 hidden items-center gap-2 rounded-lg border px-3 py-1.5 sm:flex">
<span className="text-muted-foreground text-sm">
<div className="hidden items-center gap-2 rounded-lg border border-[rgba(0,212,255,0.15)] bg-[rgba(0,212,255,0.03)] px-3 py-1.5 sm:flex">
<span className="text-xs text-[#64748b]">
{t("chat.showAssistantDetails")}
</span>
<Switch
@ -332,7 +313,7 @@ export function ChatPage() {
variant="secondary"
size="sm"
onClick={newChat}
className="h-9 gap-2"
className="h-9 gap-2 border-[rgba(0,212,255,0.2)] bg-[rgba(0,212,255,0.05)] text-[#00d4ff] hover:bg-[rgba(0,212,255,0.1)]"
>
<IconPlus className="size-4" />
<span className="hidden sm:inline">{t("chat.newChat")}</span>
@ -358,9 +339,15 @@ export function ChatPage() {
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48 relative"
>
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{/* Background orb behind text */}
<BackgroundOrb />
{/* Scan line overlay */}
<div className="scan-line-overlay absolute inset-0 pointer-events-none z-[1]" />
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8 relative z-10">
{messages.length === 0 && !isTyping && (
<ChatEmptyState
hasAvailableModels={hasAvailableModels}
@ -439,4 +426,4 @@ export function ChatPage() {
/>
</div>
)
}
}

View file

@ -26,6 +26,7 @@ export function LogsPage() {
size="sm"
onClick={clearLogs}
disabled={logs.length === 0 || clearing}
className="border-[rgba(0,212,255,0.3)] bg-[rgba(0,212,255,0.05)] text-[#00d4ff] hover:bg-[rgba(0,212,255,0.1)]"
>
<IconTrash className="size-4" />
{t("pages.logs.clear")}

View file

@ -45,92 +45,67 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-jarvis-cyan: #00d4ff;
--color-jarvis-blue: #0ea5e9;
--color-jarvis-light: #22d3ee;
--color-jarvis-surface: #0f172a;
--color-jarvis-glow: rgba(0, 212, 255, 0.15);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: #0a0e17;
--foreground: #e0f2fe;
--card: rgba(6, 20, 40, 0.7);
--card-foreground: #e0f2fe;
--popover: #111827;
--popover-foreground: #e0f2fe;
--primary: #00d4ff;
--primary-foreground: #0a0e17;
--secondary: #1e293b;
--secondary-foreground: #e0f2fe;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: rgba(0, 212, 255, 0.1);
--accent-foreground: #00d4ff;
--destructive: #ef4444;
--border: rgba(0, 212, 255, 0.15);
--input: rgba(0, 212, 255, 0.1);
--ring: #00d4ff;
--chart-1: #00d4ff;
--chart-2: #0ea5e9;
--chart-3: #22d3ee;
--chart-4: #06b6d4;
--chart-5: #0891b2;
--sidebar: #0f172a;
--sidebar-foreground: #e0f2fe;
--sidebar-primary: #00d4ff;
--sidebar-primary-foreground: #0a0e17;
--sidebar-accent: rgba(0, 212, 255, 0.1);
--sidebar-accent-foreground: #00d4ff;
--sidebar-border: rgba(0, 212, 255, 0.15);
--sidebar-ring: #00d4ff;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* Force dark mode always */
html {
color-scheme: dark;
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
scrollbar-color: rgba(0, 212, 255, 0.3) transparent;
}
body {
@apply bg-background text-foreground;
}
/* WebKit Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
@ -138,12 +113,12 @@
}
::-webkit-scrollbar-thumb {
background: var(--border);
background: rgba(0, 212, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
background: rgba(0, 212, 255, 0.5);
}
}
@ -157,25 +132,242 @@
height: calc(100svh - 3.5rem);
}
/* Typing indicator animations */
@keyframes shimmer {
0% {
background-position: 200% 0;
}
/* ===== JARVIS CUSTOM ANIMATIONS ===== */
100% {
background-position: -200% 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes fadeSlideIn {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
0% { opacity: 0; transform: translateY(4px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 5px rgba(0, 212, 255, 0.2), 0 0 10px rgba(0, 212, 255, 0.1); }
50% { box-shadow: 0 0 15px rgba(0, 212, 255, 0.4), 0 0 30px rgba(0, 212, 255, 0.15); }
}
@keyframes pulse-glow-text {
0%, 100% { text-shadow: 0 0 5px rgba(0, 212, 255, 0.4); }
50% { text-shadow: 0 0 20px rgba(0, 212, 255, 0.7), 0 0 40px rgba(0, 212, 255, 0.3); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
}
@keyframes orb-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes orb-rotate-reverse {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes typing-dot {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
@keyframes hud-corner-flash {
0%, 90%, 100% { opacity: 0.5; }
95% { opacity: 1; }
}
@keyframes scan-line {
0% { top: -2px; }
100% { top: 100%; }
}
@keyframes float-up {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes ring-pulse {
0%, 100% { stroke-opacity: 0.3; }
50% { stroke-opacity: 0.8; }
}
@keyframes voiceBar {
0% { height: 4px; opacity: 0.5; }
100% { height: 16px; opacity: 1; }
}
@keyframes scan {
0% { background-position: 0 0; }
100% { background-position: 0 100%; }
}
@keyframes holographic-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* ===== JARVIS UTILITY CLASSES ===== */
.glass-panel {
background: rgba(6, 20, 40, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 212, 255, 0.15);
}
.glass-panel-strong {
background: rgba(6, 20, 40, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 212, 255, 0.2);
}
.glow-border {
box-shadow: 0 0 5px rgba(0, 212, 255, 0.15), inset 0 0 5px rgba(0, 212, 255, 0.03);
}
.glow-border-active {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.25), 0 0 20px rgba(0, 212, 255, 0.1), inset 0 0 8px rgba(0, 212, 255, 0.05);
border-color: rgba(0, 212, 255, 0.35);
}
.hud-corners {
position: relative;
}
.hud-corners::before,
.hud-corners::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
border-color: rgba(0, 212, 255, 0.5);
animation: hud-corner-flash 4s ease-in-out infinite;
pointer-events: none;
}
.hud-corners::before {
top: -1px;
left: -1px;
border-top: 2px solid;
border-left: 2px solid;
}
.hud-corners::after {
bottom: -1px;
right: -1px;
border-bottom: 2px solid;
border-right: 2px solid;
}
.glow-text {
text-shadow: 0 0 10px rgba(0, 212, 255, 0.4), 0 0 20px rgba(0, 212, 255, 0.15);
}
.glow-text-pulse {
animation: pulse-glow-text 3s ease-in-out infinite;
}
.animate-breathe {
animation: breathe 4s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-float-up {
animation: float-up 0.4s ease-out forwards;
}
/* ===== HEX GRID BACKGROUND ===== */
.hex-grid-bg {
background-image:
linear-gradient(30deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
linear-gradient(150deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
linear-gradient(30deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
linear-gradient(150deg, rgba(0, 212, 255, 0.02) 12%, transparent 12.5%, transparent 87%, rgba(0, 212, 255, 0.02) 87.5%),
linear-gradient(60deg, rgba(0, 212, 255, 0.015) 25%, transparent 25.5%, transparent 75%, rgba(0, 212, 255, 0.015) 75%),
linear-gradient(60deg, rgba(0, 212, 255, 0.015) 25%, transparent 25.5%, transparent 75%, rgba(0, 212, 255, 0.015) 75%);
background-size: 40px 70px;
background-position: 0 0, 0 0, 20px 35px, 20px 35px, 0 0, 20px 35px;
}
/* ===== SCAN LINE ===== */
.scan-line-overlay {
position: relative;
overflow: hidden;
}
.scan-line-overlay::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.1), transparent);
animation: scan-line 8s linear infinite;
pointer-events: none;
z-index: 1;
}
/* Markdown in chat messages - Jarvis style */
.jarvis-markdown h1, .jarvis-markdown h2, .jarvis-markdown h3 {
color: #00d4ff;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
.jarvis-markdown p {
margin-bottom: 0.5em;
line-height: 1.7;
}
.jarvis-markdown code {
background: rgba(0, 212, 255, 0.08);
border: 1px solid rgba(0, 212, 255, 0.12);
border-radius: 3px;
padding: 0.1em 0.4em;
font-size: 0.875em;
color: #22d3ee;
}
.jarvis-markdown pre {
background: rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(0, 212, 255, 0.12) !important;
border-radius: 6px !important;
padding: 1em !important;
overflow-x: auto;
margin: 0.75em 0 !important;
}
.jarvis-markdown pre code {
background: none !important;
border: none !important;
padding: 0 !important;
color: #e0f2fe !important;
}
.jarvis-markdown a {
color: #00d4ff;
text-decoration: underline;
text-underline-offset: 2px;
}
.jarvis-markdown strong {
color: #e0f2fe;
font-weight: 600;
}
.jarvis-markdown blockquote {
border-left: 3px solid rgba(0, 212, 255, 0.3);
padding-left: 1em;
color: #94a3b8;
margin: 0.75em 0;
}