diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 90a1a4d..5601919 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" const SIDEBAR_KEY = "crema.shell.sidebar" import { NavLink, useNavigate } from "react-router" @@ -39,7 +39,23 @@ import { BackgroundPicker } from "~/components/layout/background-picker" import { FontSizePicker } from "~/components/layout/font-size-picker" import { SurfacePicker } from "~/components/layout/surface-picker" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover" import { profileInitials, useProfile } from "~/lib/profile" +import { signOut, useSession } from "~/lib/session" +import { + addNotification, + dismiss, + dismissAll, + markAllRead, + markRead, + seedIfEmpty, + unreadCount, + useNotifications, +} from "~/lib/notifications" import { Button } from "~/components/ui/button" import { DropdownMenu, @@ -100,12 +116,30 @@ export function AppShell({ const defaultBrand = useBrand() const defaultUser = useUser() const profile = useProfile() + const session = useSession() + const navigate = useNavigate() const brand = brandOverride ?? defaultBrand + // Prefer the live session for identity, fall back to the editable profile, + // fall back to the stub user. const user = userOverride ?? { - name: profile.name || defaultUser.name, - email: profile.email || defaultUser.email, - initials: profileInitials(profile.name || defaultUser.name), + name: session?.name || profile.name || defaultUser.name, + email: session?.email || profile.email || defaultUser.email, + initials: profileInitials( + session?.name || profile.name || defaultUser.name, + ), } + + // Protected shell: bounce to /login when there's no session. + useEffect(() => { + if (typeof window === "undefined") return + if (!session) { + const next = encodeURIComponent( + window.location.pathname + window.location.search, + ) + navigate(`/login?next=${next}`, { replace: true }) + } + }, [session, navigate]) + if (!session) return null const [expanded, setExpanded] = useState(() => { if (typeof window === "undefined") return false return localStorage.getItem(SIDEBAR_KEY) === "1" @@ -115,7 +149,6 @@ export function AppShell({ }, [expanded]) const [mobileOpen, setMobileOpen] = useState(false) const [scriptsOpen, setScriptsOpen] = useState(false) - const navigate = useNavigate() const BrandIcon = brand.icon useScriptsHotkey(() => setScriptsOpen(true)) @@ -280,14 +313,8 @@ export function AppShell({ - + + Help - + { + signOut() + navigate("/login", { replace: true }) + }} + > Sign out @@ -345,6 +379,195 @@ export function AppShell({ + ) } + +function NotificationDispatcher() { + // Hidden bridge so the action bus can create real notifications: + // fill notify-title "Hello" + // fill notify-body "Body text" + // fill notify-kind "info" # info|success|warning|error + // fill notify-href "/library" # optional + // click notify-create + const titleRef = useRef(null) + const bodyRef = useRef(null) + const kindRef = useRef(null) + const hrefRef = useRef(null) + const submit = () => { + const title = titleRef.current?.value.trim() ?? "" + if (!title) return + const body = bodyRef.current?.value.trim() || undefined + const rawKind = (kindRef.current?.value || "info").trim().toLowerCase() + const kind = ( + ["info", "success", "warning", "error"].includes(rawKind) + ? rawKind + : "info" + ) as "info" | "success" | "warning" | "error" + const href = hrefRef.current?.value.trim() || undefined + addNotification({ title, body, kind, href }) + if (titleRef.current) titleRef.current.value = "" + if (bodyRef.current) bodyRef.current.value = "" + if (kindRef.current) kindRef.current.value = "" + if (hrefRef.current) hrefRef.current.value = "" + } + return ( +
+ + + + + +
+ ) +} + +function NotificationsBell() { + const items = useNotifications() + const unread = unreadCount(items) + const navigate = useNavigate() + + useEffect(() => { + seedIfEmpty() + }, []) + + return ( + + + + + {unread > 0 && ( + + {unread > 9 ? "9+" : unread} + + )} + + + } + /> + +
+ Notifications +
+ + +
+
+
    + {items.length === 0 ? ( +
  • + No notifications. +
  • + ) : ( + items.map((n) => ( +
  • + + + +
  • + )) + )} +
+
+
+ ) +} diff --git a/app/lib/api.ts b/app/lib/api.ts new file mode 100644 index 0000000..2dbeac4 --- /dev/null +++ b/app/lib/api.ts @@ -0,0 +1,83 @@ +// API — typed fetch wrapper. Auto-injects the session token, throws on +// non-2xx with a parsed error, and supports AbortSignal for cancellation. +// +// Replace `apiBaseURL` with your backend root. The Resources route shows the +// typical usage pattern. + +import { loadSession, signOut } from "~/lib/session" + +export const apiBaseURL = "/api" + +export class ApiError extends Error { + status: number + body: unknown + constructor(message: string, status: number, body: unknown) { + super(message) + this.name = "ApiError" + this.status = status + this.body = body + } +} + +export type ApiInit = Omit & { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" + body?: unknown +} + +export async function apiFetch( + path: string, + init: ApiInit = {}, +): Promise { + const session = loadSession() + const headers = new Headers(init.headers) + if (session?.token) headers.set("Authorization", `Bearer ${session.token}`) + if (init.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + + const url = path.startsWith("http") ? path : `${apiBaseURL}${path}` + const res = await fetch(url, { + ...init, + method: init.method ?? "GET", + headers, + body: + init.body === undefined + ? undefined + : typeof init.body === "string" + ? init.body + : JSON.stringify(init.body), + }) + + if (res.status === 401) { + // Token rejected — clear session so the shell bounces to /login. + signOut() + } + + const ct = res.headers.get("Content-Type") ?? "" + const parsed = ct.includes("application/json") + ? await res.json().catch(() => null) + : await res.text().catch(() => null) + + if (!res.ok) { + const message = + (parsed && typeof parsed === "object" && "message" in parsed + ? String((parsed as { message: unknown }).message) + : null) ?? `${res.status} ${res.statusText}` + throw new ApiError(message, res.status, parsed) + } + return parsed as T +} + +/** Convenience helpers. */ +export const api = { + get: (path: string, init?: ApiInit) => + apiFetch(path, { ...init, method: "GET" }), + post: (path: string, body?: unknown, init?: ApiInit) => + apiFetch(path, { ...init, method: "POST", body }), + put: (path: string, body?: unknown, init?: ApiInit) => + apiFetch(path, { ...init, method: "PUT", body }), + patch: (path: string, body?: unknown, init?: ApiInit) => + apiFetch(path, { ...init, method: "PATCH", body }), + del: (path: string, init?: ApiInit) => + apiFetch(path, { ...init, method: "DELETE" }), +} diff --git a/app/lib/notifications.ts b/app/lib/notifications.ts new file mode 100644 index 0000000..398d71e --- /dev/null +++ b/app/lib/notifications.ts @@ -0,0 +1,155 @@ +// Notifications — small reactive store for in-app toasts/inbox items. +// Pair with @crema/notification-ui's for transient toasts; +// this store is for the appbar bell's persistent inbox. + +import { useEffect, useSyncExternalStore } from "react" + +export type NotificationKind = "info" | "success" | "warning" | "error" + +export type AppNotification = { + id: string + kind: NotificationKind + title: string + body?: string + // Optional href to open when the row is clicked. + href?: string + createdAt: number + readAt?: number +} + +const STORAGE_KEY = "crema.notifications" +const CHANGE_EVENT = "crema:notifications-change" +const MAX_ITEMS = 200 + +function newId(): string { + return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` +} + +function readFromStorage(): AppNotification[] { + if (typeof window === "undefined") return [] + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (n): n is AppNotification => + n && + typeof n.id === "string" && + typeof n.title === "string" && + typeof n.createdAt === "number" && + ["info", "success", "warning", "error"].includes(n.kind), + ) + } catch { + return [] + } +} + +function writeToStorage(items: AppNotification[]) { + if (typeof window === "undefined") return + const trimmed = items.slice(0, MAX_ITEMS) + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) + } catch { + /* quota — drop silently */ + } +} + +export function loadNotifications(): AppNotification[] { + return readFromStorage() +} + +export function addNotification( + n: Omit, +): AppNotification { + const next: AppNotification = { + ...n, + id: newId(), + createdAt: Date.now(), + } + writeToStorage([next, ...readFromStorage()]) + return next +} + +export function markRead(id: string) { + const items = readFromStorage().map((n) => + n.id === id ? { ...n, readAt: Date.now() } : n, + ) + writeToStorage(items) +} + +export function markAllRead() { + const now = Date.now() + const items = readFromStorage().map((n) => + n.readAt ? n : { ...n, readAt: now }, + ) + writeToStorage(items) +} + +export function dismiss(id: string) { + writeToStorage(readFromStorage().filter((n) => n.id !== id)) +} + +export function dismissAll() { + writeToStorage([]) +} + +let cached: AppNotification[] | null = null + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cached = null + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) onChange() + }) + return () => window.removeEventListener(CHANGE_EVENT, onChange) +} +function getSnapshot(): AppNotification[] { + if (!cached) cached = readFromStorage() + return cached +} +function getServerSnapshot(): AppNotification[] { + return [] +} + +export function useNotifications(): AppNotification[] { + const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cached = null + }, []) + return value +} + +export function unreadCount(items: AppNotification[]): number { + return items.filter((n) => !n.readAt).length +} + +/** Seed a few demo notifications on first load so the bell isn't empty. */ +export function seedIfEmpty() { + if (typeof window === "undefined") return + if (localStorage.getItem(STORAGE_KEY)) return + const now = Date.now() + const seed: AppNotification[] = [ + { + id: newId(), + kind: "info", + title: "Welcome", + body: "Tag elements with data-action and the assistant can drive them.", + href: "/assistant", + createdAt: now - 60_000, + }, + { + id: newId(), + kind: "success", + title: "Profile saved", + body: "Your display name and avatar are live across the app.", + href: "/profile", + createdAt: now - 5 * 60_000, + }, + ] + writeToStorage(seed) +} diff --git a/app/lib/resources.test.ts b/app/lib/resources.test.ts new file mode 100644 index 0000000..8b61f83 --- /dev/null +++ b/app/lib/resources.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, beforeEach } from "vitest" + +import { + createResource, + deleteResource, + listResources, + updateResource, +} from "./resources" + +describe("resources", () => { + beforeEach(() => { + localStorage.clear() + }) + + it("creates, updates, and deletes", () => { + expect(listResources()).toEqual([]) + const r = createResource({ name: "Test", owner: "Atlas" }) + expect(r.status).toBe("active") + expect(listResources()).toHaveLength(1) + + const updated = updateResource(r.id, { status: "paused" }) + expect(updated?.status).toBe("paused") + expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt) + + deleteResource(r.id) + expect(listResources()).toEqual([]) + }) + + it("ignores updates for unknown ids", () => { + expect(updateResource("missing", { name: "x" })).toBeNull() + }) +}) diff --git a/app/lib/resources.ts b/app/lib/resources.ts new file mode 100644 index 0000000..2157604 --- /dev/null +++ b/app/lib/resources.ts @@ -0,0 +1,157 @@ +// Resource store — example domain entity. +// Backed by localStorage today, but written so each call is a single function +// you can swap with `api.get/post/put/del` once you have a real backend. + +import { useEffect, useSyncExternalStore } from "react" + +export type Resource = { + id: string + name: string + status: "active" | "paused" | "archived" + owner: string + createdAt: number + updatedAt: number +} + +const STORAGE_KEY = "crema.resources" +const CHANGE_EVENT = "crema:resources-change" + +function newId() { + return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` +} + +function readFromStorage(): Resource[] { + if (typeof window === "undefined") return [] + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (r): r is Resource => + r && + typeof r.id === "string" && + typeof r.name === "string" && + ["active", "paused", "archived"].includes(r.status) && + typeof r.owner === "string" && + typeof r.createdAt === "number" && + typeof r.updatedAt === "number", + ) + } catch { + return [] + } +} + +function write(items: Resource[]) { + if (typeof window === "undefined") return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) + } catch { + /* quota */ + } +} + +// CRUD — these mirror what `api.get/post/put/del` would look like. +export function listResources(): Resource[] { + return readFromStorage() +} + +export function createResource(input: { + name: string + owner: string + status?: Resource["status"] +}): Resource { + const now = Date.now() + const r: Resource = { + id: newId(), + name: input.name, + owner: input.owner, + status: input.status ?? "active", + createdAt: now, + updatedAt: now, + } + write([r, ...readFromStorage()]) + return r +} + +export function updateResource( + id: string, + patch: Partial>, +): Resource | null { + const items = readFromStorage() + let updated: Resource | null = null + const next = items.map((r) => { + if (r.id !== id) return r + updated = { ...r, ...patch, updatedAt: Date.now() } + return updated + }) + if (updated) write(next) + return updated +} + +export function deleteResource(id: string) { + write(readFromStorage().filter((r) => r.id !== id)) +} + +let cached: Resource[] | null = null +function subscribe(cb: () => void) { + const onChange = () => { + cached = null + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) onChange() + }) + return () => window.removeEventListener(CHANGE_EVENT, onChange) +} +function getSnapshot(): Resource[] { + if (!cached) cached = readFromStorage() + return cached +} +function getServerSnapshot(): Resource[] { + return [] +} + +export function useResources(): Resource[] { + const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cached = null + }, []) + return v +} + +/** Seed a few rows on first load so the table isn't empty. */ +export function seedResourcesIfEmpty() { + if (typeof window === "undefined") return + if (localStorage.getItem(STORAGE_KEY)) return + const now = Date.now() + const seed: Resource[] = [ + { + id: newId(), + name: "Acme dashboard", + status: "active", + owner: "Atlas", + createdAt: now - 86_400_000 * 3, + updatedAt: now - 3600_000, + }, + { + id: newId(), + name: "Onboarding pipeline", + status: "paused", + owner: "Forge", + createdAt: now - 86_400_000 * 7, + updatedAt: now - 86_400_000, + }, + { + id: newId(), + name: "Q1 report draft", + status: "archived", + owner: "Inkwell", + createdAt: now - 86_400_000 * 30, + updatedAt: now - 86_400_000 * 14, + }, + ] + write(seed) +} diff --git a/app/lib/session.test.ts b/app/lib/session.test.ts new file mode 100644 index 0000000..8cf685c --- /dev/null +++ b/app/lib/session.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, beforeEach } from "vitest" + +import { hasSession, loadSession, signIn, signOut } from "./session" + +describe("session", () => { + beforeEach(() => { + localStorage.clear() + }) + + it("starts unauthenticated", () => { + expect(loadSession()).toBeNull() + expect(hasSession()).toBe(false) + }) + + it("rejects empty credentials", async () => { + await expect(signIn("", "")).rejects.toThrow(/required/i) + await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i) + expect(hasSession()).toBe(false) + }) + + it("creates a session on sign-in and clears on sign-out", async () => { + const session = await signIn("alice@example.com", "hunter2") + expect(session.email).toBe("alice@example.com") + expect(session.token).toMatch(/^dev-/) + expect(hasSession()).toBe(true) + + signOut() + expect(loadSession()).toBeNull() + expect(hasSession()).toBe(false) + }) +}) diff --git a/app/lib/session.ts b/app/lib/session.ts new file mode 100644 index 0000000..81bbdc0 --- /dev/null +++ b/app/lib/session.ts @@ -0,0 +1,129 @@ +// Session — minimal auth scaffold backed by localStorage. +// Swap loadSession/signIn/signOut for real calls (cookies + server) when you +// wire a backend. The shape here matches what AppShell + useUser expect. + +import { useEffect, useSyncExternalStore } from "react" + +import { profileInitials } from "~/lib/profile" + +export type Session = { + userId: string + name: string + email: string + token: string + // Issued at, ms since epoch. + issuedAt: number +} + +const STORAGE_KEY = "crema.session" +const CHANGE_EVENT = "crema:session-change" + +function readFromStorage(): Session | null { + if (typeof window === "undefined") return null + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial + if ( + typeof parsed.userId !== "string" || + typeof parsed.email !== "string" || + typeof parsed.token !== "string" + ) + return null + return { + userId: parsed.userId, + name: + typeof parsed.name === "string" && parsed.name.trim() + ? parsed.name + : parsed.email, + email: parsed.email, + token: parsed.token, + issuedAt: + typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(), + } + } catch { + return null + } +} + +export function loadSession(): Session | null { + return readFromStorage() +} + +/** + * Mock sign-in. Validates only that email + password are non-empty; returns + * a fake session. Replace with a real fetch to your auth endpoint. + */ +export async function signIn( + email: string, + password: string, +): Promise { + await new Promise((r) => setTimeout(r, 250)) + if (!email.trim() || !password.trim()) { + throw new Error("Email and password are required.") + } + if (!email.includes("@")) { + throw new Error("Enter a valid email address.") + } + const session: Session = { + userId: `u-${Date.now().toString(36)}`, + name: email.split("@")[0].replace(/\W/g, " ").trim() || email, + email, + token: `dev-${Math.random().toString(36).slice(2, 14)}`, + issuedAt: Date.now(), + } + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) + } + return session +} + +export function signOut() { + if (typeof window === "undefined") return + localStorage.removeItem(STORAGE_KEY) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +/** True if a non-expired session is in storage. */ +export function hasSession(): boolean { + return !!readFromStorage() +} + +let cached: Session | null = null +let cacheValid = false + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cacheValid = false + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) onChange() + }) + return () => window.removeEventListener(CHANGE_EVENT, onChange) +} +function getSnapshot(): Session | null { + if (!cacheValid) { + cached = readFromStorage() + cacheValid = true + } + return cached +} +function getServerSnapshot(): Session | null { + return null +} + +export function useSession(): Session | null { + const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cacheValid = false + }, []) + return s +} + +export function sessionInitials(session: Session | null): string { + if (!session) return "?" + return profileInitials(session.name || session.email) +} diff --git a/app/lib/threads.ts b/app/lib/threads.ts index 4065fb2..9a44111 100644 --- a/app/lib/threads.ts +++ b/app/lib/threads.ts @@ -3,7 +3,12 @@ import { useEffect, useSyncExternalStore } from "react" -export type ThreadMessage = { role: "user" | "assistant"; content: string } +export type ThreadMessage = { + role: "user" | "assistant" + content: string + /** Persona that authored this assistant message (omitted for user msgs). */ + agentId?: string +} export type Thread = { id: string diff --git a/app/routes.ts b/app/routes.ts index 40db828..9b85c53 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -8,5 +8,6 @@ export default [ route("library", "routes/library.tsx"), route("settings", "routes/settings.tsx"), route("profile", "routes/profile.tsx"), + route("login", "routes/login.tsx"), // CREMA:ROUTES ] satisfies RouteConfig diff --git a/app/routes/assistant.tsx b/app/routes/assistant.tsx index 65d30e6..1bd35b9 100644 --- a/app/routes/assistant.tsx +++ b/app/routes/assistant.tsx @@ -53,6 +53,16 @@ Agents settings: settings-agent-new, settings-agent-reset, settings-agent-activa Assistant agent picker: assistant-agent (dropdown — click to switch persona) Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-, assistant-thread-rename-, assistant-thread-delete-, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-, assistant-msg-edit-, assistant-msg-speak- Library page: library-search, library-open-, library-copy-, library-download-, library-delete- +Resources page: resources-search, resources-new-name, resources-create, resources-status-, resources-delete- +Login page: login-email, login-password, login-submit +Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-, notif-dismiss- +Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe: + fill notif-title "Reminder" + fill notif-body "Take a 5-minute break" + fill notif-kind "info" # info | success | warning | error + fill notif-href "/library" # optional, omit if no link + click notif-create +Use this any time the user asks you to remind them, leave a note, flag something, or queue an item — drop a notification. Example — User: "Go to settings and set the response cap to 1024" → "On it. @@ -391,11 +401,18 @@ function AssistantSurface({ }) // Persist conversation back into the active thread. + // Preserve any agentId already stamped on prior messages, and stamp newly + // appended assistant messages with the *currently active* agent. useEffect(() => { if (isStreaming) return + const stamped: ThreadMessage[] = messages.map((m, i) => { + const prior = thread.messages[i] + if (m.role === "user") return { role: "user", content: m.content } + const agentId = prior?.agentId ?? activeAgentId + return { role: "assistant", content: m.content, agentId } + }) updateThread(thread.id, { - messages: messages as ThreadMessage[], - // Auto-title from the first user message if the thread is still untitled. + messages: stamped, ...(thread.title === "New conversation" && messages[0]?.role === "user" ? { title: deriveTitleFromFirstMessage(messages[0].content) } @@ -667,9 +684,20 @@ function AssistantSurface({ { maxTokens: 220 }, ) const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}` + // Stamp the existing thread messages (preserve their authorship) and + // attribute the handoff note itself to the OUTGOING agent. + const stamped: ThreadMessage[] = messages.map((m, i) => { + const prior = thread.messages[i] + if (m.role === "user") return { role: "user", content: m.content } + return { + role: "assistant", + content: m.content, + agentId: prior?.agentId ?? activeAgent.id, + } + }) const next: ThreadMessage[] = [ - ...(messages as ThreadMessage[]), - { role: "assistant", content: note }, + ...stamped, + { role: "assistant", content: note, agentId: activeAgent.id }, ] updateThread(thread.id, { messages: next, agentId: target.id }) saveActiveAgentId(target.id) @@ -832,14 +860,37 @@ function AssistantSurface({ !messages.slice(i + 1).some((x) => x.role === "user") const isEditing = editingIndex === i const isUser = m.role === "user" + const msgAgentId = + !isUser + ? thread.messages[i]?.agentId ?? activeAgent?.id + : undefined + const msgAgent = msgAgentId + ? agents.find((a) => a.id === msgAgentId) ?? activeAgent + : undefined return (
+ {!isUser && msgAgent && ( + + + {agentInitials(msgAgent.name)} + + + )}
- {isUser ? "You" : "Assistant"} + {isUser + ? "You" + : (msgAgent?.name ?? "Assistant")} + {!isUser && msgAgent && ( + + · {msgAgent.role} + + )} {isPinned && ( )} @@ -1368,10 +1426,24 @@ function AssistantSurface({ responseBudget={responseBudget} onClose={() => setCompareOpen(false)} onAppend={(agentName, content) => { + const speaker = agents.find((a) => a.name === agentName) const note = `🪞 **${agentName} says:**\n\n${content}` + const stamped: ThreadMessage[] = messages.map((m, i) => { + const prior = thread.messages[i] + if (m.role === "user") return { role: "user", content: m.content } + return { + role: "assistant", + content: m.content, + agentId: prior?.agentId ?? activeAgent?.id, + } + }) const next: ThreadMessage[] = [ - ...(messages as ThreadMessage[]), - { role: "assistant", content: note }, + ...stamped, + { + role: "assistant", + content: note, + agentId: speaker?.id ?? activeAgent?.id, + }, ] updateThread(thread.id, { messages: next }) setActionLog(`Appended ${agentName}'s reply to the thread.`) diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..8798f30 --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react" +import { useNavigate, useSearchParams } from "react-router" +import { Loader2, LogIn, Sparkles } from "lucide-react" + +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Input } from "~/components/ui/input" +import { useBrand } from "~/lib/identity" +import { pageTitle } from "~/lib/page-meta" +import { signIn, useSession } from "~/lib/session" + +export const meta = () => pageTitle("Sign in") + +export default function LoginRoute() { + const navigate = useNavigate() + const [params] = useSearchParams() + const session = useSession() + const brand = useBrand() + const BrandIcon = brand.icon + + const next = params.get("next") || "/" + const [email, setEmail] = useState("you@example.com") + const [password, setPassword] = useState("hunter2") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + // Already signed in? Bounce. + useEffect(() => { + if (session) navigate(next, { replace: true }) + }, [session, next, navigate]) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + setError(null) + try { + await signIn(email, password) + navigate(next, { replace: true }) + } catch (err) { + setError(err instanceof Error ? err.message : "Sign-in failed.") + setSubmitting(false) + } + } + + return ( +
+ + +
+ +
+ Sign in to {brand.name} + + Mock auth — any email + non-empty password works. Wire{" "} + ~/lib/session.ts to your + real backend. + +
+ +
+ + + {error && ( +

+ {error} +

+ )} + +

+ + No account needed in dev — credentials aren't checked. +

+
+
+
+
+ ) +} diff --git a/app/routes/resources.tsx b/app/routes/resources.tsx index de4b030..4dda33c 100644 --- a/app/routes/resources.tsx +++ b/app/routes/resources.tsx @@ -1,6 +1,8 @@ -import { Boxes } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { Plus, Search, Trash2 } from "lucide-react" import { AppShell } from "~/components/layout/app-shell" +import { Button } from "~/components/ui/button" import { Card, CardContent, @@ -8,35 +10,172 @@ import { CardHeader, CardTitle, } from "~/components/ui/card" +import { Input } from "~/components/ui/input" +import { + createResource, + deleteResource, + seedResourcesIfEmpty, + updateResource, + useResources, + type Resource, +} from "~/lib/resources" import { pageTitle } from "~/lib/page-meta" export const meta = () => pageTitle("Resources") +const statuses: Resource["status"][] = ["active", "paused", "archived"] + export default function ResourcesRoute() { + const items = useResources() + const [query, setQuery] = useState("") + const [draftName, setDraftName] = useState("") + + useEffect(() => { + seedResourcesIfEmpty() + }, []) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + return q + ? items.filter( + (r) => + r.name.toLowerCase().includes(q) || + r.owner.toLowerCase().includes(q) || + r.status.includes(q), + ) + : items + }, [items, query]) + + const create = () => { + const name = draftName.trim() + if (!name) return + createResource({ name, owner: "You" }) + setDraftName("") + } + return ( Resources - A list/detail surface for the entities your app manages. + Example domain entity. CRUD goes through{" "} + ~/lib/resources.ts — + swap that file's calls for{" "} + api.get/post/put/del{" "} + from ~/lib/api.ts when + you have a backend. - -
-
- -
-
-

No resources yet

-

- This route is the canonical "traditional" surface. Drop in{" "} - @crema/table-ui or{" "} - @crema/data-ui when - you have data to show. -

+ +
+
+ + setQuery(e.target.value)} + placeholder="Search name, owner, status…" + className="pl-8" + />
+ setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") create() + }} + placeholder="New resource name…" + className="max-w-64" + /> +
+ +
+ + + + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((r) => ( + + + + + + + + )) + )} + +
NameOwnerStatusUpdated
+ {items.length === 0 + ? "No resources yet — add one above." + : "No matches."} +
{r.name} + {r.owner} + + + + {new Date(r.updatedAt).toLocaleDateString()} + + +
+
+ +

+ {items.length} total · {filtered.length} shown +

diff --git a/package-lock.json b/package-lock.json index 535d4d8..ecd8fde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,18 +29,80 @@ "devDependencies": { "@react-router/dev": "7.13.1", "@tailwindcss/vite": "^4.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3-geo": "^3.1.0", "@types/node": "^22", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/topojson-client": "^3.1.5", "@types/topojson-specification": "^1.0.5", + "jsdom": "^29.1.0", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^7.3.1", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.5" } }, + "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/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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", @@ -537,6 +599,161 @@ } } }, + "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.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "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.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "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.2.0" + }, + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", @@ -1131,6 +1348,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "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.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2264,6 +2499,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/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2536,6 +2778,82 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "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", @@ -2547,6 +2865,24 @@ "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" + }, + "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-geo": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", @@ -2566,6 +2902,13 @@ "@types/ms": "*" } }, + "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", @@ -2638,6 +2981,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2696,6 +3040,133 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/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/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/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/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2800,12 +3271,32 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "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/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "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", @@ -2880,6 +3371,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "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": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -3085,6 +3586,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", @@ -3480,6 +3991,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.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", @@ -3531,6 +4063,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/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -3559,6 +4105,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/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3695,6 +4248,13 @@ "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" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3779,6 +4339,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "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", @@ -3914,6 +4487,16 @@ "url": "https://opencollective.com/unified" } }, + "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", @@ -3983,6 +4566,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -4591,6 +5184,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/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4680,6 +5286,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", @@ -4895,6 +5511,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", @@ -5007,6 +5630,58 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", + "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -5400,6 +6075,16 @@ "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", + "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", @@ -5572,6 +6257,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6144,6 +6836,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.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -6459,6 +7161,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", @@ -6633,6 +7346,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.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", @@ -6785,6 +7511,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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", + "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", + "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", + "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", @@ -6845,6 +7609,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.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -6927,6 +7701,13 @@ "react": "^19.2.5" } }, + "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" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -7030,6 +7811,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/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -7260,6 +8055,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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", @@ -7483,6 +8291,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", @@ -7539,6 +8354,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", @@ -7548,6 +8370,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "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", @@ -7650,6 +8479,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/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -7668,6 +8510,13 @@ "inline-style-parser": "0.2.7" } }, + "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", @@ -7717,6 +8566,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "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.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7734,6 +8600,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -7805,6 +8681,19 @@ "node": ">=16" } }, + "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/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7928,6 +8817,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "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", @@ -8301,6 +9200,123 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/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/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", @@ -8310,6 +9326,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", @@ -8325,6 +9376,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/world-atlas": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", @@ -8411,6 +9479,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 3bff5e7..5532202 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "react-router dev", "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc", + "test": "vitest run", + "test:watch": "vitest", "sync-libs": "node scripts/sync-libs.mjs" }, "dependencies": { @@ -33,15 +35,19 @@ "devDependencies": { "@react-router/dev": "7.13.1", "@tailwindcss/vite": "^4.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3-geo": "^3.1.0", "@types/node": "^22", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/topojson-client": "^3.1.5", "@types/topojson-specification": "^1.0.5", + "jsdom": "^29.1.0", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^7.3.1", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.5" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b4a9813 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config" +import tsconfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + include: ["app/**/*.{test,spec}.{ts,tsx}"], + css: false, + }, +}) diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..09207b5 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,8 @@ +import "@testing-library/jest-dom/vitest" +import { afterEach } from "vitest" +import { cleanup } from "@testing-library/react" + +afterEach(() => { + cleanup() + if (typeof localStorage !== "undefined") localStorage.clear() +})