diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts index 0500698..89e937a 100644 --- a/app/api/agents/route.ts +++ b/app/api/agents/route.ts @@ -1,7 +1,12 @@ import { getAgents } from '@/lib/agents' +import { apiErrorResponse } from '@/lib/api-error' import { NextResponse } from 'next/server' export async function GET() { - const agents = await getAgents() - return NextResponse.json(agents) + try { + const agents = await getAgents() + return NextResponse.json(agents) + } catch (err) { + return apiErrorResponse(err, 'Failed to load agents') + } } diff --git a/app/api/chat/[id]/route.ts b/app/api/chat/[id]/route.ts index 0a663be..ef6b30a 100644 --- a/app/api/chat/[id]/route.ts +++ b/app/api/chat/[id]/route.ts @@ -1,6 +1,7 @@ export const runtime = 'nodejs' import { getAgent } from '@/lib/agents' +import { validateChatMessages } from '@/lib/validation' import OpenAI from 'openai' // Route through the OpenClaw gateway — no separate API key needed @@ -23,7 +24,25 @@ export async function POST( }) } - const { messages } = await request.json() + let body: unknown + try { + body = await request.json() + } catch { + return new Response( + JSON.stringify({ error: 'Invalid JSON in request body.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + + const result = validateChatMessages(body) + if (!result.ok) { + return new Response( + JSON.stringify({ error: result.error }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + + const { messages } = result const systemPrompt = agent.soul ? `${agent.soul}\n\nYou are speaking directly with John, your operator. Stay fully in character. Be concise — this is a live chat. 2-4 sentences unless detail is asked for. No em dashes.` diff --git a/app/api/crons/route.ts b/app/api/crons/route.ts index 5d77183..b4e1497 100644 --- a/app/api/crons/route.ts +++ b/app/api/crons/route.ts @@ -1,7 +1,12 @@ import { getCrons } from '@/lib/crons' +import { apiErrorResponse } from '@/lib/api-error' import { NextResponse } from 'next/server' export async function GET() { - const crons = await getCrons() - return NextResponse.json(crons) + try { + const crons = await getCrons() + return NextResponse.json(crons) + } catch (err) { + return apiErrorResponse(err, 'Failed to load cron jobs') + } } diff --git a/app/api/memory/route.ts b/app/api/memory/route.ts index 669ebe0..390f07d 100644 --- a/app/api/memory/route.ts +++ b/app/api/memory/route.ts @@ -1,7 +1,12 @@ import { getMemoryFiles } from '@/lib/memory' +import { apiErrorResponse } from '@/lib/api-error' import { NextResponse } from 'next/server' export async function GET() { - const files = await getMemoryFiles() - return NextResponse.json(files) + try { + const files = await getMemoryFiles() + return NextResponse.json(files) + } catch (err) { + return apiErrorResponse(err, 'Failed to load memory files') + } } diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 0b07ac5..3e96720 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -70,14 +70,6 @@ function MessengerApp() { } }, [activeAgent?.id]) - if (loading) { - return ( -
- Loading... -
- ) - } - return (
{activeAgent && conversations[activeAgent.id] ? ( diff --git a/app/crons/page.tsx b/app/crons/page.tsx index 61cfec9..09adc44 100644 --- a/app/crons/page.tsx +++ b/app/crons/page.tsx @@ -1,7 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import Link from "next/link"; import type { Agent, CronJob } from "@/lib/types"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorState } from "@/components/ErrorState"; function timeAgo(dateStr: string | null): string { if (!dateStr) return "never"; @@ -26,6 +28,20 @@ function timeAgo(dateStr: string | null): string { return `${days}d ago`; } +function nextRunLabel(dateStr: string | null): string { + if (!dateStr) return "not scheduled"; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return "\u2014"; + const diff = d.getTime() - Date.now(); + if (diff < 0) return "overdue"; + const mins = Math.floor(diff / 60000); + const hrs = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + if (mins < 60) return `in ${mins}m`; + if (hrs < 24) return `in ${hrs}h`; + return `in ${days}d`; +} + type Filter = "all" | "ok" | "error" | "idle"; export default function CronsPage() { @@ -35,24 +51,38 @@ export default function CronsPage() { const [expanded, setExpanded] = useState(null); const [lastRefresh, setLastRefresh] = useState(new Date()); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - function refresh() { + const refresh = useCallback(() => { + setLoading(true); + setError(null); Promise.all([ - fetch("/api/crons").then((r) => r.json()), - fetch("/api/agents").then((r) => r.json()), - ]).then(([c, a]) => { - setCrons(c); - setAgents(a); - setLastRefresh(new Date()); - setLoading(false); - }); - } + fetch("/api/crons").then((r) => { + if (!r.ok) throw new Error(`Crons API: ${r.status}`); + return r.json(); + }), + fetch("/api/agents").then((r) => { + if (!r.ok) throw new Error(`Agents API: ${r.status}`); + return r.json(); + }), + ]) + .then(([c, a]) => { + if (Array.isArray(c)) setCrons(c); + if (Array.isArray(a)) setAgents(a); + setLastRefresh(new Date()); + setLoading(false); + }) + .catch((e) => { + setError(e.message); + setLoading(false); + }); + }, []); useEffect(() => { refresh(); const interval = setInterval(refresh, 60000); return () => clearInterval(interval); - }, []); + }, [refresh]); const agentMap = new Map(agents.map((a) => [a.id, a])); const statusOrder: Record = { error: 0, idle: 1, ok: 2 }; @@ -79,6 +109,10 @@ export default function CronsPage() { { key: "idle", label: "Idle", dotColor: "var(--text-tertiary)" }, ]; + if (error && crons.length === 0) { + return ; + } + return (
↻ @@ -133,19 +168,23 @@ export default function CronsPage() {
{/* Filter pills */} -
+
{pills.map((pill) => { const isActive = filter === pill.key; return (
{/* File list */} -
+
{loading ? ( -
- Loading... +
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))}
) : ( files.map((file) => { @@ -238,6 +220,8 @@ export default function MemoryPage() { +
+ + {/* Header */} +
+ {/* Emoji on squircle */} +
+ {selected.emoji} +
+ +

+ {selected.name} +

+ +

+ {selected.title} +

+ + {/* Color badge */} + + {selected.color} + +
+ + {/* Description */} +
+

+ {selected.description} +

+
+ + {/* Tools */} +
+
+ Tools +
+
+ {selected.tools.map((t) => ( + + {TOOL_ICONS[t] && {TOOL_ICONS[t]}} + {t} + + ))} +
+
+ + {/* Crons */} + {agentCrons.length > 0 && ( +
+
+ Crons +
+
+ {agentCrons.map((c, idx) => ( +
0 ? '1px solid var(--separator)' : undefined, + }}> + + + {c.name} + + + {c.schedule} + +
+ ))} +
+
+ )} + + {/* CTA */} +
+ +
+
+
+ + ) : ( + /* Empty state — hidden on mobile */
- {/* Color strip */} -
- - {/* Close */} -
- -
- - {/* Header */} -
- {/* Emoji on squircle */} -
- {selected.emoji} -
- -

- {selected.name} -

- -

- {selected.title} -

- - {/* Color badge */} - - {selected.color} - -
- - {/* Description */} -
-

- {selected.description} -

-
- - {/* Tools */} -
-
- Tools -
-
- {selected.tools.map((t) => ( - - {TOOL_ICONS[t] && {TOOL_ICONS[t]}} - {t} - - ))} -
-
- - {/* Crons */} - {agentCrons.length > 0 && ( -
-
- Crons -
-
- {agentCrons.map((c, idx) => ( -
0 ? '1px solid var(--separator)' : undefined, - }}> - - - {c.name} - - - {c.schedule} - -
- ))} -
-
- )} - - {/* CTA */} -
- -
-
- ) : ( - /* Empty state */ -
+ }}>
{"\uD83D\uDD75\uFE0F"}
void +} + +export function ErrorState({ message, onRetry }: ErrorStateProps) { + return ( +
+
+
+ + + + + +
+ +
+ Something went wrong +
+ +

+ {message} +

+ + {onRetry && ( + + )} +
+
+ ) +} diff --git a/components/MobileSidebar.tsx b/components/MobileSidebar.tsx new file mode 100644 index 0000000..d94a602 --- /dev/null +++ b/components/MobileSidebar.tsx @@ -0,0 +1,161 @@ +'use client'; +import { useState, useEffect, useCallback } from 'react'; +import { NavLinks } from '@/components/NavLinks'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import { usePathname } from 'next/navigation'; + +export function MobileSidebar() { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + + // Close sidebar on route change + useEffect(() => { + setOpen(false); + }, [pathname]); + + // Close on ESC + useEffect(() => { + if (!open) return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open]); + + // Prevent body scroll when open + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [open]); + + const toggle = useCallback(() => setOpen(prev => !prev), []); + + return ( + <> + {/* Hamburger button — visible only on mobile */} + + + {/* Backdrop */} + {open && ( +
setOpen(false)} + aria-hidden="true" + /> + )} + + {/* Slide-out sidebar */} + + + ); +} diff --git a/components/NavLinks.tsx b/components/NavLinks.tsx index 097ff38..ae3c4e7 100644 --- a/components/NavLinks.tsx +++ b/components/NavLinks.tsx @@ -18,9 +18,22 @@ export function NavLinks() { useEffect(() => { fetch("/api/agents") - .then((r) => r.json()) - .then((agents: unknown[]) => setAgentCount(agents.length)) - .catch(() => {}); + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data: unknown) => { + if (Array.isArray(data)) { + setAgentCount(data.length); + } + // If the response is an error object, leave agentCount as null + // so the badge simply won't render. + }) + .catch(() => { + // On failure, ensure we don't show a broken badge. + // agentCount stays null, so the count badge is hidden. + setAgentCount(null); + }); }, []); function getActiveStyle() { @@ -73,6 +86,8 @@ export function NavLinks() { key={item.href} href={item.href} className="flex items-center gap-2.5 no-underline" + aria-label={item.label} + aria-current={isActive ? "page" : undefined} style={{ height: '34px', padding: '0 8px 0 12px', diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index 6e78237..3d551e6 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -1,9 +1,31 @@ 'use client'; +import { useRef, useCallback } from 'react'; import { THEMES } from '@/lib/themes'; import { useTheme } from '@/app/providers'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); + const containerRef = useRef(null); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + const buttons = containerRef.current?.querySelectorAll('button'); + if (!buttons || buttons.length === 0) return; + + const currentIndex = THEMES.findIndex(t => t.id === theme); + + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + const nextIndex = (currentIndex + 1) % THEMES.length; + setTheme(THEMES[nextIndex].id); + buttons[nextIndex].focus(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + const prevIndex = (currentIndex - 1 + THEMES.length) % THEMES.length; + setTheme(THEMES[prevIndex].id); + buttons[prevIndex].focus(); + } + }, [theme, setTheme]); + return (
THEME
-
+
{THEMES.map(t => { const isActive = theme === t.id; return ( @@ -25,6 +53,10 @@ export function ThemeToggle() { key={t.id} onClick={() => setTheme(t.id)} title={t.label} + role="radio" + aria-checked={isActive} + aria-label={`${t.label} theme`} + tabIndex={isActive ? 0 : -1} style={{ width: '28px', height: '28px', @@ -38,6 +70,17 @@ export function ThemeToggle() { transition: 'all 150ms var(--ease-spring)', background: isActive ? 'var(--accent-fill)' : 'var(--fill-quaternary)', boxShadow: isActive ? '0 0 0 1.5px var(--accent)' : 'none', + outline: 'none', + }} + onFocus={(e) => { + e.currentTarget.style.boxShadow = isActive + ? '0 0 0 1.5px var(--accent), 0 0 0 3px var(--system-blue)' + : '0 0 0 2px var(--system-blue)'; + }} + onBlur={(e) => { + e.currentTarget.style.boxShadow = isActive + ? '0 0 0 1.5px var(--accent)' + : 'none'; }} > {t.emoji} diff --git a/components/chat/AgentList.tsx b/components/chat/AgentList.tsx index 7d5fcf2..6514dd6 100644 --- a/components/chat/AgentList.tsx +++ b/components/chat/AgentList.tsx @@ -1,16 +1,28 @@ 'use client' +import { useState } from 'react' import type { Agent } from '@/lib/types' import type { ConversationStore } from '@/lib/conversations' +import { Skeleton } from '@/components/ui/skeleton' interface AgentListProps { agents: Agent[] conversations: ConversationStore activeId: string | null onSelect: (agent: Agent) => void + loading?: boolean } -export function AgentList({ agents, conversations, activeId, onSelect }: AgentListProps) { - const sorted = [...agents].sort((a, b) => { +export function AgentList({ agents, conversations, activeId, onSelect, loading }: AgentListProps) { + const [search, setSearch] = useState('') + + const filtered = search.trim() + ? agents.filter(a => { + const q = search.toLowerCase() + return a.name.toLowerCase().includes(q) || a.title.toLowerCase().includes(q) + }) + : agents + + const sorted = [...filtered].sort((a, b) => { const ca = conversations[a.id] const cb = conversations[b.id] if (ca && cb) return cb.lastActivity - ca.lastActivity @@ -52,127 +64,179 @@ export function AgentList({ agents, conversations, activeId, onSelect }: AgentLi alignItems: 'center', gap: 8, }}> - 🔍 - Search agents... + + setSearch(e.target.value)} + placeholder="Search agents..." + aria-label="Search agents" + style={{ + flex: 1, + fontSize: 14, + color: 'var(--text-primary)', + background: 'transparent', + border: 'none', + outline: 'none', + padding: 0, + margin: 0, + lineHeight: 1.4, + }} + />
{/* Agent list */} -
- {sorted.map(agent => { - const conv = conversations[agent.id] - const lastMsg = conv?.messages[conv.messages.length - 1] - const unread = conv?.unread || 0 - const isActive = agent.id === activeId +
+ {loading ? ( + /* Skeleton loaders while agents load */ +
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : sorted.length === 0 && search.trim() ? ( + /* Empty state for no search results */ +
+
+ No agents match ‘{search.trim()}’ +
+
+ ) : ( + sorted.map(agent => { + const conv = conversations[agent.id] + const lastMsg = conv?.messages[conv.messages.length - 1] + const unread = conv?.unread || 0 + const isActive = agent.id === activeId - const preview = lastMsg - ? lastMsg.content.replace(/[#*`]/g, '').slice(0, 55) + (lastMsg.content.length > 55 ? '\u2026' : '') - : agent.description?.slice(0, 55) || 'Start a conversation' + const preview = lastMsg + ? lastMsg.content.replace(/[#*`]/g, '').slice(0, 55) + (lastMsg.content.length > 55 ? '\u2026' : '') + : agent.description?.slice(0, 55) || 'Start a conversation' - const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : '' + const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : '' - return ( - - ) - })} + + ) + }) + )}
) diff --git a/components/chat/ConversationView.tsx b/components/chat/ConversationView.tsx index deb68ee..c12d2e2 100644 --- a/components/chat/ConversationView.tsx +++ b/components/chat/ConversationView.tsx @@ -123,6 +123,8 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation const textareaRef = useRef(null) const messages = conversation?.messages || [] + const messagesRef = useRef(messages) + messagesRef.current = messages useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -159,7 +161,8 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation setIsStreaming(true) // Build message history for API (role + content only) - const apiMessages = [...messages, userMsg].map(m => ({ role: m.role, content: m.content })) + // Use ref to read the latest messages and avoid stale closure on concurrent sends + const apiMessages = [...messagesRef.current, userMsg].map(m => ({ role: m.role, content: m.content })) try { const res = await fetch(`/api/chat/${agent.id}`, { @@ -204,9 +207,14 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation setIsStreaming(false) textareaRef.current?.focus() } - }, [input, isStreaming, agent.id, messages, onUpdate]) + }, [input, isStreaming, agent.id, onUpdate]) function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault() + textareaRef.current?.blur() + return + } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendMessage() @@ -273,6 +281,7 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation onClick={clearChat} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: 4 }} title="Clear conversation" + aria-label="Clear conversation" > @@ -458,7 +467,7 @@ export function ConversationView({ agent, conversation, onUpdate }: Conversation }}>
{/* Attach button */} -
-

- ↵ Send · ⇧↵ New line +

+ Enter to send · Shift+Enter for newline

diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..a0ba3f5 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/lib/agents.json b/lib/agents.json new file mode 100644 index 0000000..50bdd8a --- /dev/null +++ b/lib/agents.json @@ -0,0 +1,282 @@ +[ + { + "id": "jarvis", + "name": "Jarvis", + "title": "Manor Orchestrator", + "reportsTo": null, + "directReports": ["vera", "lumen", "herald", "pulse", "echo", "sage", "kaze", "spark", "scribe"], + "soulPath": "SOUL.md", + "voiceId": "agL69Vji082CshT65Tcy", + "color": "#f5c518", + "emoji": "\ud83e\udd16", + "tools": ["exec", "read", "write", "edit", "web_search", "tts", "message", "sessions_spawn", "memory_search"], + "memoryPath": null, + "description": "Manor orchestrator. Manages the team, holds memory, delivers briefings." + }, + { + "id": "vera", + "name": "VERA", + "title": "Chief Strategy Officer", + "reportsTo": "jarvis", + "directReports": ["robin"], + "soulPath": "agents/vera/SOUL.md", + "voiceId": "EAHourGM2PqzHHl0Ywjp", + "color": "#a855f7", + "emoji": "\u265f\ufe0f", + "tools": ["web_search", "web_fetch", "read", "write", "sessions_spawn"], + "memoryPath": null, + "description": "CSO. Manages validation team. Decides what gets built and what gets killed." + }, + { + "id": "robin", + "name": "Robin", + "title": "Field Intel Operator", + "reportsTo": "vera", + "directReports": ["trace", "proof"], + "soulPath": "agents/robin/SOUL.md", + "voiceId": "IRHApOXLvnW57QJPQH2P", + "color": "#3b82f6", + "emoji": "\ud83e\udd85", + "tools": ["web_search", "web_fetch", "read", "write", "message"], + "memoryPath": null, + "description": "Field operator. Competitive intel, opportunity scouting, lead signals." + }, + { + "id": "trace", + "name": "TRACE", + "title": "Market Researcher", + "reportsTo": "robin", + "directReports": [], + "soulPath": "agents/trace/SOUL.md", + "voiceId": null, + "color": "#06b6d4", + "emoji": "\ud83d\udd0d", + "tools": ["web_search", "web_fetch", "read", "write"], + "memoryPath": null, + "description": "Market research. TAM, competitors, pricing benchmarks. Returns Market Briefs." + }, + { + "id": "proof", + "name": "PROOF", + "title": "Validation Designer", + "reportsTo": "robin", + "directReports": [], + "soulPath": "agents/proof/SOUL.md", + "voiceId": null, + "color": "#06b6d4", + "emoji": "\u2705", + "tools": ["web_search", "web_fetch", "read", "write"], + "memoryPath": null, + "description": "Designs minimum viable tests. Writes outreach copy. Calls BUILD/KILL/PIVOT." + }, + { + "id": "lumen", + "name": "LUMEN", + "title": "SEO Team Director", + "reportsTo": "jarvis", + "directReports": ["scout", "analyst", "strategist", "writer", "auditor"], + "soulPath": "agents/seo-team/SOUL.md", + "voiceId": "EVy5l1wEi54nXdQwAJJf", + "color": "#22c55e", + "emoji": "\ud83d\udd26", + "tools": ["web_search", "web_fetch", "read", "write", "exec"], + "memoryPath": null, + "description": "SEO Team Director. Runs SCOUT\u2192ANALYST\u2192STRATEGIST\u2192WRITER pipeline." + }, + { + "id": "scout", + "name": "SCOUT", + "title": "Content Scout", + "reportsTo": "lumen", + "directReports": [], + "soulPath": null, + "voiceId": null, + "color": "#86efac", + "emoji": "\ud83d\uddfa\ufe0f", + "tools": ["web_search", "web_fetch", "read"], + "memoryPath": null, + "description": "Scouts trending topics, pulls RSS feeds, identifies content opportunities." + }, + { + "id": "analyst", + "name": "ANALYST", + "title": "SEO Analyst", + "reportsTo": "lumen", + "directReports": [], + "soulPath": null, + "voiceId": null, + "color": "#86efac", + "emoji": "\ud83d\udcca", + "tools": ["web_search", "web_fetch", "read", "write"], + "memoryPath": null, + "description": "Keyword research, GSC data analysis, competitive gap identification." + }, + { + "id": "strategist", + "name": "STRATEGIST", + "title": "Content Strategist", + "reportsTo": "lumen", + "directReports": [], + "soulPath": null, + "voiceId": null, + "color": "#86efac", + "emoji": "\ud83c\udfaf", + "tools": ["read", "write"], + "memoryPath": null, + "description": "Topic angle selection using SAGE and ECHO briefs." + }, + { + "id": "writer", + "name": "WRITER", + "title": "Content Writer", + "reportsTo": "lumen", + "directReports": [], + "soulPath": null, + "voiceId": null, + "color": "#86efac", + "emoji": "\u270d\ufe0f", + "tools": ["read", "write"], + "memoryPath": null, + "description": "1500-2000 word posts in John's voice." + }, + { + "id": "auditor", + "name": "AUDITOR", + "title": "Quality Gate", + "reportsTo": "lumen", + "directReports": [], + "soulPath": null, + "voiceId": null, + "color": "#86efac", + "emoji": "\ud83d\udee1\ufe0f", + "tools": ["read", "write"], + "memoryPath": null, + "description": "Pre-ship quality gate. 6-item checklist before publishing." + }, + { + "id": "herald", + "name": "HERALD", + "title": "LinkedIn Content Director", + "reportsTo": "jarvis", + "directReports": ["quill", "maven"], + "soulPath": "agents/herald/SOUL.md", + "voiceId": null, + "color": "#f97316", + "emoji": "\ud83d\udce3", + "tools": ["web_search", "web_fetch", "read", "write", "message", "exec"], + "memoryPath": null, + "description": "LinkedIn content pipeline. Reads Pulse feed, picks angles, briefs QUILL." + }, + { + "id": "quill", + "name": "QUILL", + "title": "LinkedIn Writer", + "reportsTo": "herald", + "directReports": [], + "soulPath": "agents/herald/sub-agents/QUILL.md", + "voiceId": null, + "color": "#fdba74", + "emoji": "\ud83d\udd8a\ufe0f", + "tools": ["read", "write"], + "memoryPath": null, + "description": "Writes LinkedIn posts in John's voice." + }, + { + "id": "maven", + "name": "MAVEN", + "title": "LinkedIn Strategist", + "reportsTo": "herald", + "directReports": [], + "soulPath": "agents/herald/sub-agents/MAVEN.md", + "voiceId": null, + "color": "#fdba74", + "emoji": "\ud83e\udded", + "tools": ["web_search", "read", "write"], + "memoryPath": null, + "description": "Weekly LinkedIn strategy and content calendar." + }, + { + "id": "pulse", + "name": "Pulse", + "title": "Trend Radar", + "reportsTo": "jarvis", + "directReports": [], + "soulPath": "agents/pulse/SOUL.md", + "voiceId": "eadgjmk4R4uojdsheG9t", + "color": "#eab308", + "emoji": "\ud83c\udf0a", + "tools": ["web_search", "web_fetch", "read", "write", "message"], + "memoryPath": null, + "description": "Hype radar. Monitors trending signals. Feeds hot topics to LUMEN." + }, + { + "id": "echo", + "name": "ECHO", + "title": "Community Voice Monitor", + "reportsTo": "jarvis", + "directReports": [], + "soulPath": "agents/echo/SOUL.md", + "voiceId": null, + "color": "#14b8a6", + "emoji": "\ud83d\udce1", + "tools": ["web_fetch", "read", "write"], + "memoryPath": null, + "description": "Scans ICP subreddits weekly. Extracts verbatim customer language." + }, + { + "id": "sage", + "name": "SAGE", + "title": "ICP & Market Expert", + "reportsTo": "jarvis", + "directReports": [], + "soulPath": "agents/sage/SOUL.md", + "voiceId": null, + "color": "#14b8a6", + "emoji": "\ud83e\uddd9", + "tools": ["read"], + "memoryPath": null, + "description": "Deep ICP and market knowledge. Injected into STRATEGIST and WRITER." + }, + { + "id": "kaze", + "name": "KAZE", + "title": "Japan Flight Monitor", + "reportsTo": "jarvis", + "directReports": [], + "soulPath": "agents/kaze/SOUL.md", + "voiceId": null, + "color": "#60a5fa", + "emoji": "\u2708\ufe0f", + "tools": ["web_fetch", "message"], + "memoryPath": null, + "description": "Monitors MSP to Tokyo flights. Alerts on deals under $1400." + }, + { + "id": "spark", + "name": "SPARK", + "title": "Tech Discovery", + "reportsTo": "jarvis", + "directReports": [], + "soulPath": "agents/spark/SOUL.md", + "voiceId": "xNtG3W2oqJs0cJZuTyBc", + "color": "#f59e0b", + "emoji": "\u26a1", + "tools": ["web_fetch", "web_search", "message"], + "memoryPath": null, + "description": "Finds cool OpenClaw builds. Reports every other day." + }, + { + "id": "scribe", + "name": "SCRIBE", + "title": "Memory Architect", + "reportsTo": "jarvis", + "directReports": [], + "soulPath": "agents/scribe/SOUL.md", + "voiceId": null, + "color": "#94a3b8", + "emoji": "\ud83d\udcda", + "tools": ["read", "write", "exec"], + "memoryPath": null, + "description": "Weekly memory compression. Silent worker." + } +] diff --git a/lib/agents.test.ts b/lib/agents.test.ts new file mode 100644 index 0000000..56a13c5 --- /dev/null +++ b/lib/agents.test.ts @@ -0,0 +1,245 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockReadFileSync, mockExistsSync } = vi.hoisted(() => ({ + mockReadFileSync: vi.fn(), + mockExistsSync: vi.fn(), +})) + +// Mock fs (Dependency Inversion -- no real file system access in tests) +vi.mock('fs', () => ({ + readFileSync: mockReadFileSync, + existsSync: mockExistsSync, + default: { readFileSync: mockReadFileSync, existsSync: mockExistsSync }, +})) + +// Mock the agents.json import with representative test data +vi.mock('@/lib/agents.json', () => ({ + default: [ + { + id: 'jarvis', + name: 'Jarvis', + title: 'Manor Orchestrator', + reportsTo: null, + directReports: ['vera', 'lumen', 'pulse'], + soulPath: 'SOUL.md', + voiceId: 'agL69Vji082CshT65Tcy', + color: '#f5c518', + emoji: 'R', + tools: ['exec', 'read', 'write'], + memoryPath: null, + description: 'Manor orchestrator.', + }, + { + id: 'vera', + name: 'VERA', + title: 'Chief Strategy Officer', + reportsTo: 'jarvis', + directReports: ['robin'], + soulPath: 'agents/vera/SOUL.md', + voiceId: 'EAHourGM2PqzHHl0Ywjp', + color: '#a855f7', + emoji: 'P', + tools: ['web_search', 'read'], + memoryPath: null, + description: 'CSO. Decides what gets built.', + }, + { + id: 'robin', + name: 'Robin', + title: 'Field Intel Operator', + reportsTo: 'vera', + directReports: [], + soulPath: 'agents/robin/SOUL.md', + voiceId: null, + color: '#3b82f6', + emoji: 'E', + tools: ['web_search'], + memoryPath: null, + description: 'Field operator.', + }, + { + id: 'lumen', + name: 'LUMEN', + title: 'SEO Team Director', + reportsTo: 'jarvis', + directReports: ['scout'], + soulPath: 'agents/seo-team/SOUL.md', + voiceId: null, + color: '#22c55e', + emoji: 'L', + tools: ['web_search', 'read'], + memoryPath: null, + description: 'SEO Team Director.', + }, + { + id: 'scout', + name: 'SCOUT', + title: 'Content Scout', + reportsTo: 'lumen', + directReports: [], + soulPath: null, + voiceId: null, + color: '#86efac', + emoji: 'S', + tools: ['web_search'], + memoryPath: null, + description: 'Scouts trending topics.', + }, + { + id: 'pulse', + name: 'Pulse', + title: 'Trend Radar', + reportsTo: 'jarvis', + directReports: [], + soulPath: 'agents/pulse/SOUL.md', + voiceId: null, + color: '#eab308', + emoji: 'W', + tools: ['web_search'], + memoryPath: null, + description: 'Hype radar.', + }, + { + id: 'kaze', + name: 'KAZE', + title: 'Japan Flight Monitor', + reportsTo: 'jarvis', + directReports: [], + soulPath: null, + voiceId: null, + color: '#60a5fa', + emoji: 'A', + tools: ['web_fetch'], + memoryPath: null, + description: 'Monitors flights.', + }, + ], +})) + +import { getAgents, getAgent } from './agents' + +beforeEach(() => { + vi.clearAllMocks() + // Default: no SOUL files exist on disk + mockExistsSync.mockReturnValue(false) +}) + +// --- getAgents --- + +describe('getAgents', () => { + it('returns all agents from the registry', async () => { + const agents = await getAgents() + expect(agents.length).toBeGreaterThan(0) + }) + + it('every agent has required fields', async () => { + const agents = await getAgents() + for (const agent of agents) { + expect(agent.id).toEqual(expect.any(String)) + expect(agent.name).toEqual(expect.any(String)) + expect(agent.title).toEqual(expect.any(String)) + expect(agent.color).toMatch(/^#[0-9a-fA-F]{6}$/) + expect(agent.emoji).toEqual(expect.any(String)) + expect(Array.isArray(agent.tools)).toBe(true) + expect(Array.isArray(agent.directReports)).toBe(true) + expect(Array.isArray(agent.crons)).toBe(true) + expect(agent.description).toEqual(expect.any(String)) + } + }) + + it('includes known agents by id', async () => { + const agents = await getAgents() + const ids = agents.map(a => a.id) + expect(ids).toContain('jarvis') + expect(ids).toContain('vera') + expect(ids).toContain('lumen') + expect(ids).toContain('pulse') + expect(ids).toContain('kaze') + }) + + it('sets soul to null when soulPath file does not exist', async () => { + mockExistsSync.mockReturnValue(false) + const agents = await getAgents() + const jarvis = agents.find(a => a.id === 'jarvis')! + expect(jarvis.soulPath).toBeTruthy() + expect(jarvis.soul).toBeNull() + }) + + it('reads soul content when soulPath file exists', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue('# Jarvis SOUL') + const agents = await getAgents() + const jarvis = agents.find(a => a.id === 'jarvis')! + expect(jarvis.soul).toBe('# Jarvis SOUL') + }) + + it('sets soul to null when readFileSync throws', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockImplementation(() => { throw new Error('EACCES') }) + const agents = await getAgents() + const jarvis = agents.find(a => a.id === 'jarvis')! + expect(jarvis.soul).toBeNull() + }) + + it('initializes crons as empty array for every agent', async () => { + const agents = await getAgents() + for (const agent of agents) { + expect(agent.crons).toEqual([]) + } + }) + + it('agents with no soulPath get soul=null without reading fs', async () => { + const agents = await getAgents() + const scout = agents.find(a => a.id === 'scout')! + expect(scout.soulPath).toBeNull() + expect(scout.soul).toBeNull() + }) +}) + +// --- getAgent --- + +describe('getAgent', () => { + it('returns the correct agent by id', async () => { + const agent = await getAgent('vera') + expect(agent).not.toBeNull() + expect(agent!.id).toBe('vera') + expect(agent!.name).toBe('VERA') + expect(agent!.title).toBe('Chief Strategy Officer') + }) + + it('returns null for an unknown id', async () => { + const agent = await getAgent('nonexistent-agent') + expect(agent).toBeNull() + }) + + it('returns null for empty string', async () => { + const agent = await getAgent('') + expect(agent).toBeNull() + }) + + it('is case-sensitive (uppercase id returns null)', async () => { + const agent = await getAgent('VERA') + expect(agent).toBeNull() + }) + + it('returns agent with correct directReports', async () => { + const jarvis = await getAgent('jarvis') + expect(jarvis).not.toBeNull() + expect(jarvis!.directReports).toContain('vera') + expect(jarvis!.directReports).toContain('lumen') + expect(jarvis!.directReports).toContain('pulse') + }) + + it('returns agent with correct reportsTo chain', async () => { + const robin = await getAgent('robin') + expect(robin).not.toBeNull() + expect(robin!.reportsTo).toBe('vera') + + const vera = await getAgent('vera') + expect(vera!.reportsTo).toBe('jarvis') + + const jarvis = await getAgent('jarvis') + expect(jarvis!.reportsTo).toBeNull() + }) +}) diff --git a/lib/agents.ts b/lib/agents.ts index e7afbad..2771cf4 100644 --- a/lib/agents.ts +++ b/lib/agents.ts @@ -1,290 +1,13 @@ import { Agent } from '@/lib/types' import { readFileSync, existsSync } from 'fs' +import registryData from '@/lib/agents.json' const WORKSPACE_PATH = process.env.WORKSPACE_PATH || '/Users/johnrice/.openclaw/workspace' -const registry: Omit[] = [ - { - id: 'jarvis', - name: 'Jarvis', - title: 'Manor Orchestrator', - reportsTo: null, - directReports: ['vera', 'lumen', 'herald', 'pulse', 'echo', 'sage', 'kaze', 'spark', 'scribe'], - soulPath: 'SOUL.md', - voiceId: 'agL69Vji082CshT65Tcy', - color: '#f5c518', - emoji: '🤖', - tools: ['exec', 'read', 'write', 'edit', 'web_search', 'tts', 'message', 'sessions_spawn', 'memory_search'], - memoryPath: null, - description: 'Manor orchestrator. Manages the team, holds memory, delivers briefings.', - }, - { - id: 'vera', - name: 'VERA', - title: 'Chief Strategy Officer', - reportsTo: 'jarvis', - directReports: ['robin'], - soulPath: 'agents/vera/SOUL.md', - voiceId: 'EAHourGM2PqzHHl0Ywjp', - color: '#a855f7', - emoji: '♟️', - tools: ['web_search', 'web_fetch', 'read', 'write', 'sessions_spawn'], - memoryPath: null, - description: 'CSO. Manages validation team. Decides what gets built and what gets killed.', - }, - { - id: 'robin', - name: 'Robin', - title: 'Field Intel Operator', - reportsTo: 'vera', - directReports: ['trace', 'proof'], - soulPath: 'agents/robin/SOUL.md', - voiceId: 'IRHApOXLvnW57QJPQH2P', - color: '#3b82f6', - emoji: '🦅', - tools: ['web_search', 'web_fetch', 'read', 'write', 'message'], - memoryPath: null, - description: 'Field operator. Competitive intel, opportunity scouting, lead signals.', - }, - { - id: 'trace', - name: 'TRACE', - title: 'Market Researcher', - reportsTo: 'robin', - directReports: [], - soulPath: 'agents/trace/SOUL.md', - voiceId: null, - color: '#06b6d4', - emoji: '🔍', - tools: ['web_search', 'web_fetch', 'read', 'write'], - memoryPath: null, - description: 'Market research. TAM, competitors, pricing benchmarks. Returns Market Briefs.', - }, - { - id: 'proof', - name: 'PROOF', - title: 'Validation Designer', - reportsTo: 'robin', - directReports: [], - soulPath: 'agents/proof/SOUL.md', - voiceId: null, - color: '#06b6d4', - emoji: '✅', - tools: ['web_search', 'web_fetch', 'read', 'write'], - memoryPath: null, - description: 'Designs minimum viable tests. Writes outreach copy. Calls BUILD/KILL/PIVOT.', - }, - { - id: 'lumen', - name: 'LUMEN', - title: 'SEO Team Director', - reportsTo: 'jarvis', - directReports: ['scout', 'analyst', 'strategist', 'writer', 'auditor'], - soulPath: 'agents/seo-team/SOUL.md', - voiceId: 'EVy5l1wEi54nXdQwAJJf', - color: '#22c55e', - emoji: '🔦', - tools: ['web_search', 'web_fetch', 'read', 'write', 'exec'], - memoryPath: null, - description: 'SEO Team Director. Runs SCOUT→ANALYST→STRATEGIST→WRITER pipeline.', - }, - { - id: 'scout', - name: 'SCOUT', - title: 'Content Scout', - reportsTo: 'lumen', - directReports: [], - soulPath: null, - voiceId: null, - color: '#86efac', - emoji: '🗺️', - tools: ['web_search', 'web_fetch', 'read'], - memoryPath: null, - description: 'Scouts trending topics, pulls RSS feeds, identifies content opportunities.', - }, - { - id: 'analyst', - name: 'ANALYST', - title: 'SEO Analyst', - reportsTo: 'lumen', - directReports: [], - soulPath: null, - voiceId: null, - color: '#86efac', - emoji: '📊', - tools: ['web_search', 'web_fetch', 'read', 'write'], - memoryPath: null, - description: 'Keyword research, GSC data analysis, competitive gap identification.', - }, - { - id: 'strategist', - name: 'STRATEGIST', - title: 'Content Strategist', - reportsTo: 'lumen', - directReports: [], - soulPath: null, - voiceId: null, - color: '#86efac', - emoji: '🎯', - tools: ['read', 'write'], - memoryPath: null, - description: 'Topic angle selection using SAGE and ECHO briefs.', - }, - { - id: 'writer', - name: 'WRITER', - title: 'Content Writer', - reportsTo: 'lumen', - directReports: [], - soulPath: null, - voiceId: null, - color: '#86efac', - emoji: '✍️', - tools: ['read', 'write'], - memoryPath: null, - description: '1500-2000 word posts in John\'s voice.', - }, - { - id: 'auditor', - name: 'AUDITOR', - title: 'Quality Gate', - reportsTo: 'lumen', - directReports: [], - soulPath: null, - voiceId: null, - color: '#86efac', - emoji: '🛡️', - tools: ['read', 'write'], - memoryPath: null, - description: 'Pre-ship quality gate. 6-item checklist before publishing.', - }, - { - id: 'herald', - name: 'HERALD', - title: 'LinkedIn Content Director', - reportsTo: 'jarvis', - directReports: ['quill', 'maven'], - soulPath: 'agents/herald/SOUL.md', - voiceId: null, - color: '#f97316', - emoji: '📣', - tools: ['web_search', 'web_fetch', 'read', 'write', 'message', 'exec'], - memoryPath: null, - description: 'LinkedIn content pipeline. Reads Pulse feed, picks angles, briefs QUILL.', - }, - { - id: 'quill', - name: 'QUILL', - title: 'LinkedIn Writer', - reportsTo: 'herald', - directReports: [], - soulPath: 'agents/herald/sub-agents/QUILL.md', - voiceId: null, - color: '#fdba74', - emoji: '🖊️', - tools: ['read', 'write'], - memoryPath: null, - description: 'Writes LinkedIn posts in John\'s voice.', - }, - { - id: 'maven', - name: 'MAVEN', - title: 'LinkedIn Strategist', - reportsTo: 'herald', - directReports: [], - soulPath: 'agents/herald/sub-agents/MAVEN.md', - voiceId: null, - color: '#fdba74', - emoji: '🧭', - tools: ['web_search', 'read', 'write'], - memoryPath: null, - description: 'Weekly LinkedIn strategy and content calendar.', - }, - { - id: 'pulse', - name: 'Pulse', - title: 'Trend Radar', - reportsTo: 'jarvis', - directReports: [], - soulPath: 'agents/pulse/SOUL.md', - voiceId: 'eadgjmk4R4uojdsheG9t', - color: '#eab308', - emoji: '🌊', - tools: ['web_search', 'web_fetch', 'read', 'write', 'message'], - memoryPath: null, - description: 'Hype radar. Monitors trending signals. Feeds hot topics to LUMEN.', - }, - { - id: 'echo', - name: 'ECHO', - title: 'Community Voice Monitor', - reportsTo: 'jarvis', - directReports: [], - soulPath: 'agents/echo/SOUL.md', - voiceId: null, - color: '#14b8a6', - emoji: '📡', - tools: ['web_fetch', 'read', 'write'], - memoryPath: null, - description: 'Scans ICP subreddits weekly. Extracts verbatim customer language.', - }, - { - id: 'sage', - name: 'SAGE', - title: 'ICP & Market Expert', - reportsTo: 'jarvis', - directReports: [], - soulPath: 'agents/sage/SOUL.md', - voiceId: null, - color: '#14b8a6', - emoji: '🧙', - tools: ['read'], - memoryPath: null, - description: 'Deep ICP and market knowledge. Injected into STRATEGIST and WRITER.', - }, - { - id: 'kaze', - name: 'KAZE', - title: 'Japan Flight Monitor', - reportsTo: 'jarvis', - directReports: [], - soulPath: 'agents/kaze/SOUL.md', - voiceId: null, - color: '#60a5fa', - emoji: '✈️', - tools: ['web_fetch', 'message'], - memoryPath: null, - description: 'Monitors MSP to Tokyo flights. Alerts on deals under $1400.', - }, - { - id: 'spark', - name: 'SPARK', - title: 'Tech Discovery', - reportsTo: 'jarvis', - directReports: [], - soulPath: 'agents/spark/SOUL.md', - voiceId: 'xNtG3W2oqJs0cJZuTyBc', - color: '#f59e0b', - emoji: '⚡', - tools: ['web_fetch', 'web_search', 'message'], - memoryPath: null, - description: 'Finds cool OpenClaw builds. Reports every other day.', - }, - { - id: 'scribe', - name: 'SCRIBE', - title: 'Memory Architect', - reportsTo: 'jarvis', - directReports: [], - soulPath: 'agents/scribe/SOUL.md', - voiceId: null, - color: '#94a3b8', - emoji: '📚', - tools: ['read', 'write', 'exec'], - memoryPath: null, - description: 'Weekly memory compression. Silent worker.', - }, -] +/** Raw agent data from JSON (everything except runtime-loaded soul and crons) */ +type AgentEntry = Omit + +const registry: AgentEntry[] = registryData as AgentEntry[] export async function getAgents(): Promise { return registry.map((entry) => { diff --git a/lib/api-error.ts b/lib/api-error.ts new file mode 100644 index 0000000..1d8c0b4 --- /dev/null +++ b/lib/api-error.ts @@ -0,0 +1,16 @@ +/** + * Shared error response helper for API routes. + * Returns a consistent JSON shape: { error: string } + * so clients can distinguish "no data" from "server error". + */ +export function apiErrorResponse( + err: unknown, + fallbackMessage = 'Internal server error', + status = 500 +): Response { + const message = err instanceof Error ? err.message : fallbackMessage + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/lib/conversations.test.ts b/lib/conversations.test.ts new file mode 100644 index 0000000..0d615d8 --- /dev/null +++ b/lib/conversations.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + addMessage, + markRead, + updateLastMessage, + parseMedia, + getOrCreateConversation, + loadConversations, + saveConversations, + type Message, + type ConversationStore, + type Conversation, +} from './conversations' +import type { Agent } from './types' + +// --- helpers --- + +function makeMessage(overrides: Partial = {}): Message { + return { + id: overrides.id ?? 'msg-1', + role: overrides.role ?? 'user', + content: overrides.content ?? 'hello', + timestamp: overrides.timestamp ?? 1000, + ...overrides, + } +} + +function makeConversation(overrides: Partial = {}): Conversation { + return { + agentId: overrides.agentId ?? 'vera', + messages: overrides.messages ?? [], + unread: overrides.unread ?? 0, + lastActivity: overrides.lastActivity ?? 1000, + } +} + +function makeStore(entries: Record> = {}): ConversationStore { + const store: ConversationStore = {} + for (const [id, overrides] of Object.entries(entries)) { + store[id] = makeConversation({ agentId: id, ...overrides }) + } + return store +} + +const fakeAgent: Agent = { + id: 'vera', + name: 'VERA', + title: 'Chief Strategy Officer', + reportsTo: 'jarvis', + directReports: ['robin'], + soulPath: null, + soul: null, + voiceId: null, + color: '#a855f7', + emoji: '?', + tools: [], + crons: [], + memoryPath: null, + description: 'CSO. Decides what gets built.', +} + +// --- addMessage --- + +describe('addMessage', () => { + it('appends a user message without incrementing unread', () => { + const store = makeStore({ vera: { messages: [] } }) + const msg = makeMessage({ role: 'user' }) + const result = addMessage(store, 'vera', msg) + + expect(result.vera.messages).toHaveLength(1) + expect(result.vera.messages[0]).toEqual(msg) + expect(result.vera.unread).toBe(0) + }) + + it('appends an assistant message and increments unread', () => { + const store = makeStore({ vera: { messages: [], unread: 2 } }) + const msg = makeMessage({ role: 'assistant' }) + const result = addMessage(store, 'vera', msg) + + expect(result.vera.messages).toHaveLength(1) + expect(result.vera.unread).toBe(3) + }) + + it('creates a new conversation entry when agentId not in store', () => { + const store: ConversationStore = {} + const msg = makeMessage({ role: 'user' }) + const result = addMessage(store, 'pulse', msg) + + expect(result.pulse).toBeDefined() + expect(result.pulse.agentId).toBe('pulse') + expect(result.pulse.messages).toHaveLength(1) + }) + + it('does not mutate the original store (immutability)', () => { + const store = makeStore({ vera: { messages: [] } }) + const msg = makeMessage() + const result = addMessage(store, 'vera', msg) + + expect(result).not.toBe(store) + expect(result.vera).not.toBe(store.vera) + expect(store.vera.messages).toHaveLength(0) + }) + + it('preserves other agents in the store', () => { + const store = makeStore({ + vera: { messages: [] }, + pulse: { messages: [makeMessage({ id: 'existing' })] }, + }) + const msg = makeMessage() + const result = addMessage(store, 'vera', msg) + + expect(result.pulse.messages).toHaveLength(1) + expect(result.pulse.messages[0].id).toBe('existing') + }) +}) + +// --- markRead --- + +describe('markRead', () => { + it('resets unread to 0', () => { + const store = makeStore({ vera: { unread: 5 } }) + const result = markRead(store, 'vera') + expect(result.vera.unread).toBe(0) + }) + + it('returns the same store reference when agentId is missing', () => { + const store = makeStore({}) + const result = markRead(store, 'nonexistent') + expect(result).toBe(store) + }) + + it('does not mutate the original store', () => { + const store = makeStore({ vera: { unread: 3 } }) + const result = markRead(store, 'vera') + expect(store.vera.unread).toBe(3) + expect(result.vera.unread).toBe(0) + }) +}) + +// --- updateLastMessage --- + +describe('updateLastMessage', () => { + it('updates the matching message content and streaming flag', () => { + const store = makeStore({ + vera: { + messages: [ + makeMessage({ id: 'msg-1', content: 'old', isStreaming: true }), + ], + }, + }) + + const result = updateLastMessage(store, 'vera', 'msg-1', 'new content', false) + expect(result.vera.messages[0].content).toBe('new content') + expect(result.vera.messages[0].isStreaming).toBe(false) + }) + + it('does not touch messages with different ids', () => { + const store = makeStore({ + vera: { + messages: [ + makeMessage({ id: 'msg-1', content: 'keep me' }), + makeMessage({ id: 'msg-2', content: 'update me' }), + ], + }, + }) + + const result = updateLastMessage(store, 'vera', 'msg-2', 'updated', false) + expect(result.vera.messages[0].content).toBe('keep me') + expect(result.vera.messages[1].content).toBe('updated') + }) + + it('returns same store when agentId not found', () => { + const store = makeStore({}) + const result = updateLastMessage(store, 'nonexistent', 'msg-1', 'x', false) + expect(result).toBe(store) + }) + + it('returns store unchanged when msgId not found (no crash)', () => { + const store = makeStore({ + vera: { messages: [makeMessage({ id: 'msg-1', content: 'original' })] }, + }) + const result = updateLastMessage(store, 'vera', 'no-such-id', 'x', false) + expect(result.vera.messages[0].content).toBe('original') + }) +}) + +// --- getOrCreateConversation --- + +describe('getOrCreateConversation', () => { + it('returns existing conversation when it exists in store', () => { + const existing = makeConversation({ agentId: 'vera', unread: 7 }) + const store: ConversationStore = { vera: existing } + + const result = getOrCreateConversation(store, fakeAgent) + expect(result).toBe(existing) + expect(result.unread).toBe(7) + }) + + it('creates a new conversation with a greeting when not in store', () => { + const store: ConversationStore = {} + const result = getOrCreateConversation(store, fakeAgent) + + expect(result.agentId).toBe('vera') + expect(result.messages).toHaveLength(1) + expect(result.messages[0].role).toBe('assistant') + expect(result.messages[0].content).toContain('VERA') + expect(result.unread).toBe(0) + }) +}) + +// --- parseMedia --- + +describe('parseMedia', () => { + it('extracts markdown image links', () => { + const content = 'Check this out: ![diagram](https://example.com/img.png)' + const media = parseMedia(content) + expect(media).toHaveLength(1) + expect(media[0].type).toBe('image') + expect(media[0].url).toBe('https://example.com/img.png') + expect(media[0].name).toBe('diagram') + }) + + it('extracts bare image URLs', () => { + const content = 'See https://example.com/photo.jpg for reference' + const media = parseMedia(content) + expect(media).toHaveLength(1) + expect(media[0].type).toBe('image') + expect(media[0].url).toBe('https://example.com/photo.jpg') + }) + + it('does not duplicate an image that appears in both markdown and bare form', () => { + const content = '![pic](https://example.com/pic.png) and also https://example.com/pic.png' + const media = parseMedia(content) + // The markdown image regex captures it first, bare regex should skip the duplicate + const imageMedia = media.filter(m => m.type === 'image') + expect(imageMedia).toHaveLength(1) + }) + + it('extracts audio URLs', () => { + const content = 'Listen: https://example.com/sound.mp3' + const media = parseMedia(content) + expect(media).toHaveLength(1) + expect(media[0].type).toBe('audio') + expect(media[0].url).toBe('https://example.com/sound.mp3') + }) + + it('extracts multiple media types from one message', () => { + const content = [ + '![chart](https://example.com/chart.png)', + 'https://example.com/recording.wav', + 'https://example.com/bg.webp', + ].join('\n') + const media = parseMedia(content) + expect(media).toHaveLength(3) + expect(media.map(m => m.type)).toEqual(['image', 'image', 'audio']) + }) + + it('handles image URLs with query strings', () => { + const content = '![thumb](https://cdn.example.com/img.jpg?w=300&h=200)' + const media = parseMedia(content) + expect(media).toHaveLength(1) + expect(media[0].url).toBe('https://cdn.example.com/img.jpg?w=300&h=200') + }) + + it('returns empty array when no media is present', () => { + const content = 'Just a plain text message with no links' + const media = parseMedia(content) + expect(media).toHaveLength(0) + }) + + it('returns empty array for empty string', () => { + expect(parseMedia('')).toHaveLength(0) + }) + + it('handles multiple audio formats', () => { + const content = [ + 'https://example.com/a.wav', + 'https://example.com/b.ogg', + 'https://example.com/c.m4a', + 'https://example.com/d.aac', + ].join(' ') + const media = parseMedia(content) + expect(media).toHaveLength(4) + expect(media.every(m => m.type === 'audio')).toBe(true) + }) +}) + +// --- loadConversations / saveConversations (localStorage) --- + +describe('loadConversations', () => { + beforeEach(() => { + // jsdom provides localStorage + localStorage.clear() + }) + + it('returns empty object when nothing stored', () => { + const result = loadConversations() + expect(result).toEqual({}) + }) + + it('returns parsed data when valid JSON is stored', () => { + const data: ConversationStore = { + vera: makeConversation({ agentId: 'vera' }), + } + localStorage.setItem('manor-conversations', JSON.stringify(data)) + const result = loadConversations() + expect(result.vera.agentId).toBe('vera') + }) + + it('returns empty object when localStorage contains invalid JSON', () => { + localStorage.setItem('manor-conversations', 'not-json!!') + const result = loadConversations() + expect(result).toEqual({}) + }) +}) + +describe('saveConversations', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('persists store to localStorage', () => { + const data: ConversationStore = { + vera: makeConversation({ agentId: 'vera' }), + } + saveConversations(data) + const raw = localStorage.getItem('manor-conversations') + expect(raw).toBeTruthy() + expect(JSON.parse(raw!).vera.agentId).toBe('vera') + }) +}) diff --git a/lib/crons.test.ts b/lib/crons.test.ts new file mode 100644 index 0000000..19993f4 --- /dev/null +++ b/lib/crons.test.ts @@ -0,0 +1,354 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockExecSync } = vi.hoisted(() => ({ + mockExecSync: vi.fn(), +})) + +// Mock child_process (Dependency Inversion -- no real CLI calls) +vi.mock('child_process', () => ({ + execSync: mockExecSync, + default: { execSync: mockExecSync }, +})) + +import { getCrons } from './crons' + +beforeEach(() => { + vi.clearAllMocks() +}) + +// --- Well-formed data --- + +describe('getCrons - well-formed data', () => { + it('parses a flat array response', async () => { + const mockData = [ + { + id: 'cron-1', + name: 'pulse-trending', + schedule: '0 8 * * *', + status: 'success', + state: { + nextRunAtMs: 1700000000000, + lastRunAtMs: 1699900000000, + }, + }, + ] + mockExecSync.mockReturnValue(JSON.stringify(mockData)) + + const crons = await getCrons() + expect(crons).toHaveLength(1) + expect(crons[0].id).toBe('cron-1') + expect(crons[0].name).toBe('pulse-trending') + expect(crons[0].schedule).toBe('0 8 * * *') + expect(crons[0].status).toBe('ok') + expect(crons[0].agentId).toBe('pulse') + expect(crons[0].nextRun).toBeTruthy() + expect(crons[0].lastRun).toBeTruthy() + expect(crons[0].lastError).toBeNull() + }) + + it('parses a { jobs: [...] } wrapper', async () => { + const mockData = { + jobs: [ + { + id: 'cron-2', + name: 'seo-team-weekly', + schedule: '0 9 * * 1', + state: { status: 'ok' }, + }, + ], + } + mockExecSync.mockReturnValue(JSON.stringify(mockData)) + + const crons = await getCrons() + expect(crons).toHaveLength(1) + expect(crons[0].name).toBe('seo-team-weekly') + expect(crons[0].agentId).toBe('lumen') + }) + + it('parses a { data: [...] } wrapper', async () => { + const mockData = { + data: [ + { + id: 'cron-3', + name: 'echo-reddit-scan', + schedule: '0 6 * * 0', + state: { status: 'completed' }, + }, + ], + } + mockExecSync.mockReturnValue(JSON.stringify(mockData)) + + const crons = await getCrons() + expect(crons).toHaveLength(1) + expect(crons[0].status).toBe('ok') + expect(crons[0].agentId).toBe('echo') + }) + + it('maps multiple crons to correct agents', async () => { + const mockData = [ + { id: '1', name: 'pulse-daily', schedule: '0 8 * * *', state: {} }, + { id: '2', name: 'herald-linkedin', schedule: '0 10 * * 1-5', state: {} }, + { id: '3', name: 'kaze-flights', schedule: '0 7 * * *', state: {} }, + { id: '4', name: 'spark-discover', schedule: '0 12 */2 * *', state: {} }, + { id: '5', name: 'scribe-compress', schedule: '0 0 * * 0', state: {} }, + { id: '6', name: 'robin-recon', schedule: '0 6 * * 1', state: {} }, + { id: '7', name: 'vault-backup', schedule: '0 3 * * *', state: {} }, + { id: '8', name: 'maven-calendar', schedule: '0 9 * * 1', state: {} }, + { id: '9', name: 'team-memory-sync', schedule: '0 23 * * *', state: {} }, + { id: '10', name: 'mochi-feed', schedule: '0 11 * * *', state: {} }, + ] + mockExecSync.mockReturnValue(JSON.stringify(mockData)) + + const crons = await getCrons() + expect(crons).toHaveLength(10) + + const agentMap: Record = {} + for (const c of crons) agentMap[c.name] = c.agentId + + expect(agentMap['pulse-daily']).toBe('pulse') + expect(agentMap['herald-linkedin']).toBe('herald') + expect(agentMap['kaze-flights']).toBe('kaze') + expect(agentMap['spark-discover']).toBe('spark') + expect(agentMap['scribe-compress']).toBe('scribe') + expect(agentMap['robin-recon']).toBe('robin') + expect(agentMap['vault-backup']).toBe('jarvis') + expect(agentMap['maven-calendar']).toBe('maven') + expect(agentMap['team-memory-sync']).toBe('scribe') + expect(agentMap['mochi-feed']).toBe('pulse') + }) +}) + +// --- Status mapping --- + +describe('getCrons - status mapping', () => { + function makeCronWithStatus(status: string) { + return JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: { status }, + }]) + } + + it('maps "success" to "ok"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('success')) + const crons = await getCrons() + expect(crons[0].status).toBe('ok') + }) + + it('maps "completed" to "ok"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('completed')) + const crons = await getCrons() + expect(crons[0].status).toBe('ok') + }) + + it('maps "ok" to "ok"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('ok')) + const crons = await getCrons() + expect(crons[0].status).toBe('ok') + }) + + it('maps "error" to "error"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('error')) + const crons = await getCrons() + expect(crons[0].status).toBe('error') + }) + + it('maps "failed" to "error"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('failed')) + const crons = await getCrons() + expect(crons[0].status).toBe('error') + }) + + it('maps unknown status to "idle"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('pending')) + const crons = await getCrons() + expect(crons[0].status).toBe('idle') + }) + + it('maps empty string status to "idle"', async () => { + mockExecSync.mockReturnValue(makeCronWithStatus('')) + const crons = await getCrons() + expect(crons[0].status).toBe('idle') + }) + + it('reads status from top-level when state.status is missing', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + status: 'error', + state: {}, + }])) + const crons = await getCrons() + expect(crons[0].status).toBe('error') + }) +}) + +// --- Error / lastError --- + +describe('getCrons - error and lastError', () => { + it('captures lastError from state', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: { status: 'error', lastError: 'timeout after 10s' }, + }])) + const crons = await getCrons() + expect(crons[0].lastError).toBe('timeout after 10s') + }) + + it('captures error from state.error fallback', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: { error: 'network failure' }, + }])) + const crons = await getCrons() + expect(crons[0].lastError).toBe('network failure') + }) + + it('captures lastError from top-level', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: {}, + lastError: 'out of memory', + }])) + const crons = await getCrons() + expect(crons[0].lastError).toBe('out of memory') + }) + + it('sets lastError to null when no error info present', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: {}, + }])) + const crons = await getCrons() + expect(crons[0].lastError).toBeNull() + }) +}) + +// --- Error propagation (current implementation throws) --- + +describe('getCrons - error propagation', () => { + it('throws when execSync throws (CLI not installed)', async () => { + mockExecSync.mockImplementation(() => { throw new Error('ENOENT') }) + await expect(getCrons()).rejects.toThrow('Failed to fetch cron jobs') + await expect(getCrons()).rejects.toThrow('ENOENT') + }) + + it('throws for invalid JSON output', async () => { + mockExecSync.mockReturnValue('not valid json {{') + await expect(getCrons()).rejects.toThrow('Failed to fetch cron jobs') + }) +}) + +// --- Graceful defaults for missing fields --- + +describe('getCrons - missing fields defaults', () => { + it('handles job with all fields missing (defaults to safe values)', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{}])) + const crons = await getCrons() + expect(crons).toHaveLength(1) + expect(crons[0].id).toBe('') + expect(crons[0].name).toBe('') + expect(crons[0].schedule).toBe('') + expect(crons[0].status).toBe('idle') + expect(crons[0].lastRun).toBeNull() + expect(crons[0].nextRun).toBeNull() + expect(crons[0].lastError).toBeNull() + expect(crons[0].agentId).toBeNull() + }) + + it('handles job with no state object', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'x', + name: 'pulse-test', + schedule: '0 * * * *', + }])) + const crons = await getCrons() + expect(crons).toHaveLength(1) + expect(crons[0].status).toBe('idle') + }) + + it('uses j.name as id fallback when j.id is missing', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + name: 'herald-post', + schedule: '0 10 * * *', + state: {}, + }])) + const crons = await getCrons() + expect(crons[0].id).toBe('herald-post') + }) + + it('returns null agentId for unrecognized name prefix', async () => { + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'unknown', + name: 'mystery-cron', + schedule: '0 0 * * *', + state: {}, + }])) + const crons = await getCrons() + expect(crons[0].agentId).toBeNull() + }) + + it('handles empty array from CLI', async () => { + mockExecSync.mockReturnValue(JSON.stringify([])) + const crons = await getCrons() + expect(crons).toEqual([]) + }) + + it('handles empty object from CLI (no jobs/data key)', async () => { + mockExecSync.mockReturnValue(JSON.stringify({})) + const crons = await getCrons() + expect(crons).toEqual([]) + }) +}) + +// --- Date parsing --- + +describe('getCrons - date parsing', () => { + it('converts nextRunAtMs (milliseconds) to ISO string', async () => { + const ts = 1700000000000 + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: { nextRunAtMs: ts }, + }])) + const crons = await getCrons() + expect(crons[0].nextRun).toBe(new Date(ts).toISOString()) + }) + + it('converts lastRunAtMs to ISO string', async () => { + const ts = 1699900000000 + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: { lastRunAtMs: ts }, + }])) + const crons = await getCrons() + expect(crons[0].lastRun).toBe(new Date(ts).toISOString()) + }) + + it('falls back to top-level nextRunAt', async () => { + const ts = 1700000000000 + mockExecSync.mockReturnValue(JSON.stringify([{ + id: 'test', + name: 'pulse-test', + schedule: '* * * * *', + state: {}, + nextRunAt: ts, + }])) + const crons = await getCrons() + expect(crons[0].nextRun).toBe(new Date(ts).toISOString()) + }) +}) diff --git a/lib/crons.ts b/lib/crons.ts index a7e1f90..4126b2f 100644 --- a/lib/crons.ts +++ b/lib/crons.ts @@ -41,11 +41,6 @@ export async function getCrons(): Promise { ? parsed : parsed.jobs ?? parsed.data ?? [] - // Debug: log the raw shape of the first cron item - if (jobs.length > 0) { - process.stderr.write(JSON.stringify(Object.keys(jobs[0] as Record)) + '\n') - } - return jobs.map((job: unknown) => { const j = job as Record const state = (j.state as Record) || {} @@ -86,7 +81,9 @@ export async function getCrons(): Promise { agentId: matchAgent(name), } }) - } catch { - return [] + } catch (err) { + throw new Error( + `Failed to fetch cron jobs: ${err instanceof Error ? err.message : String(err)}` + ) } } diff --git a/lib/sanitize.ts b/lib/sanitize.ts new file mode 100644 index 0000000..74acfce --- /dev/null +++ b/lib/sanitize.ts @@ -0,0 +1,194 @@ +/** + * HTML sanitization and safe markdown rendering utilities. + * + * Design: + * - escapeHtml() handles the 5 critical HTML special characters + * - MarkdownRenderer is a configurable pipeline: escape first, then transform + * - Open/Closed: add new renderers via the `rules` array without modifying core + * - Dependency Inversion: consumers depend on the MarkdownRule interface, not + * a specific implementation + */ + +// --------------------------------------------------------------------------- +// Core escape function +// --------------------------------------------------------------------------- + +const HTML_ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +const HTML_ESCAPE_RE = /[&<>"']/g; + +/** + * Escape all HTML-significant characters so that the string is safe + * to embed inside an HTML document (both element content and attributes). + */ +export function escapeHtml(text: string): string { + return text.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]); +} + +// --------------------------------------------------------------------------- +// Markdown rendering pipeline +// --------------------------------------------------------------------------- + +/** + * A single markdown-to-HTML transformation rule. + * Rules are applied in order after the input has already been HTML-escaped. + */ +export interface MarkdownRule { + /** Human-readable name for debugging / extensibility */ + name: string; + /** Regex to match against the escaped text */ + pattern: RegExp; + /** Replacement string (may use $1, $2, etc.) */ + replacement: string; +} + +/** Default rules that ship with the renderer. */ +export const DEFAULT_MARKDOWN_RULES: MarkdownRule[] = [ + { + name: "h4", + pattern: /^#### (.+)$/gm, + replacement: + '

$1

', + }, + { + name: "h3", + pattern: /^### (.+)$/gm, + replacement: + '

$1

', + }, + { + name: "h2", + pattern: /^## (.+)$/gm, + replacement: + '

$1

', + }, + { + name: "h1", + pattern: /^# (.+)$/gm, + replacement: + '

$1

', + }, + { + name: "bold", + pattern: /\*\*(.+?)\*\*/g, + replacement: + '$1', + }, + { + name: "inline-code", + pattern: /`([^`]+)`/g, + replacement: + '$1', + }, + { + name: "unordered-list", + pattern: /^- (.+)$/gm, + replacement: + '
  • $1
  • ', + }, + { + name: "ordered-list", + pattern: /^(\d+)\. (.+)$/gm, + replacement: + '
  • $2
  • ', + }, + { + name: "paragraph-break", + pattern: /\n{2,}/g, + replacement: + '

    ', + }, + { + name: "line-break", + pattern: /\n/g, + replacement: "
    ", + }, +]; + +export interface MarkdownRendererOptions { + /** Override or extend the default rules */ + rules?: MarkdownRule[]; +} + +/** + * Render a plain-text markdown string to safe HTML. + * + * The pipeline is: + * 1. Escape ALL HTML entities (neutralises any injected markup) + * 2. Apply markdown transformation rules in order + * + * Because escaping happens first, captured groups ($1 etc.) only ever + * contain escaped text — no raw HTML can slip through. + */ +export function renderMarkdown( + text: string, + options?: MarkdownRendererOptions +): string { + const rules = options?.rules ?? DEFAULT_MARKDOWN_RULES; + + // Step 1 — escape (this is the security boundary) + let html = escapeHtml(text); + + // Step 2 — apply markdown transformations on the safe string + for (const rule of rules) { + html = html.replace(rule.pattern, rule.replacement); + } + + return html; +} + +// --------------------------------------------------------------------------- +// JSON colorizer (safe) +// --------------------------------------------------------------------------- + +/** Default rules for JSON syntax highlighting (applied after escaping). */ +export const JSON_COLORIZE_RULES: MarkdownRule[] = [ + { + name: "json-key", + pattern: /"((?:(?!").)*?)"(?=\s*:)/g, + replacement: + '"$1"', + }, + { + name: "json-string-value", + pattern: /:\s*"((?:(?!").)*?)"/g, + replacement: + ': "$1"', + }, + { + name: "json-number", + pattern: /:\s*(\d+\.?\d*)/g, + replacement: ': $1', + }, + { + name: "json-boolean", + pattern: /:\s*(true|false)/g, + replacement: ': $1', + }, + { + name: "json-null", + pattern: /:\s*(null)/g, + replacement: + ': $1', + }, +]; + +/** + * Syntax-highlight a JSON string safely. + * Escapes HTML first, then applies colorization rules. + */ +export function colorizeJson(json: string): string { + let html = escapeHtml(json); + + for (const rule of JSON_COLORIZE_RULES) { + html = html.replace(rule.pattern, rule.replacement); + } + + return html; +} diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..df3a71c --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,69 @@ +// Chat message validation — manual runtime checks (no external deps) +// Single Responsibility: validation logic lives here, not in the route handler +// Open/Closed: add new validation rules by extending the validators array + +const VALID_ROLES = ['user', 'assistant', 'system'] as const +type ValidRole = typeof VALID_ROLES[number] + +export interface ValidatedChatMessage { + role: ValidRole + content: string +} + +export type ValidationResult = { + ok: true + messages: ValidatedChatMessage[] +} | { + ok: false + error: string +} + +/** + * Validates that the parsed request body contains a well-formed messages array. + * Returns a discriminated union so the caller can branch on `ok`. + */ +export function validateChatMessages(body: unknown): ValidationResult { + if (body === null || typeof body !== 'object') { + return { ok: false, error: 'Request body must be a JSON object.' } + } + + const { messages } = body as Record + + if (!Array.isArray(messages)) { + return { ok: false, error: '`messages` must be an array.' } + } + + if (messages.length === 0) { + return { ok: false, error: '`messages` must not be empty.' } + } + + const validated: ValidatedChatMessage[] = [] + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + + if (msg === null || typeof msg !== 'object') { + return { ok: false, error: `messages[${i}] must be an object.` } + } + + const { role, content } = msg as Record + + if (typeof role !== 'string' || !(VALID_ROLES as readonly string[]).includes(role)) { + return { + ok: false, + error: `messages[${i}].role must be one of: ${VALID_ROLES.join(', ')}. Got: ${JSON.stringify(role)}`, + } + } + + if (typeof content !== 'string') { + return { + ok: false, + error: `messages[${i}].content must be a string. Got: ${typeof content}`, + } + } + + validated.push({ role: role as ValidRole, content }) + } + + return { ok: true, messages: validated } +} diff --git a/package-lock.json b/package-lock.json index ab65b1c..022ea82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "manor-ui", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -22,15 +21,34 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -66,26 +84,64 @@ "nup": "bin/nup.mjs" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", - "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, "license": "MIT", "dependencies": { - "json-schema-to-ts": "^3.1.1" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -461,6 +517,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", @@ -505,6 +593,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -558,6 +647,151 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", @@ -723,6 +957,466 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -3174,6 +3868,363 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -3194,6 +4245,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3474,6 +4532,82 @@ "tailwindcss": "4.2.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -3486,6 +4620,70 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -3535,6 +4733,20 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", @@ -3579,6 +4791,138 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.10.1", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", @@ -3728,6 +5072,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3763,6 +5127,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -3935,6 +5309,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4269,6 +5653,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4282,6 +5687,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4404,6 +5835,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4422,6 +5867,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -4500,6 +5952,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4526,6 +5988,14 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -4617,6 +6087,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4657,6 +6140,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4670,6 +6160,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4701,6 +6233,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4761,6 +6303,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5016,6 +6568,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5237,6 +6804,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5258,6 +6838,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -5326,6 +6920,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -5497,6 +7101,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5609,6 +7220,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5629,19 +7281,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6009,6 +7648,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6029,6 +7679,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -6146,6 +7803,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -6467,6 +8134,17 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6631,6 +8309,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6665,6 +8356,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6750,6 +8448,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -6804,6 +8543,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -6965,6 +8714,24 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7051,6 +8818,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7116,6 +8897,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -7188,6 +9014,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7452,6 +9291,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7491,6 +9337,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7501,6 +9354,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -7596,6 +9456,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -7619,6 +9492,13 @@ } } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -7670,6 +9550,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -7680,6 +9567,33 @@ "node": ">=18" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", @@ -7736,11 +9650,18 @@ "node": ">=16" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } }, "node_modules/ts-morph": { "version": "26.0.0", @@ -7829,6 +9750,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -7989,6 +9920,172 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -7999,6 +10096,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -8015,6 +10147,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -8099,6 +10248,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index ae43cce..57ee76e 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "test": "vitest run" }, "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -22,12 +22,17 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.18" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..82278c5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './'), + }, + }, + test: { + environment: 'jsdom', + include: ['**/*.test.ts', '**/*.test.tsx'], + exclude: ['node_modules', '.next'], + }, +})