omarsol commited on
Commit
15e53f0
·
1 Parent(s): b1899ff

Add frontend regression test suite

Browse files
.github/workflows/ci.yml CHANGED
@@ -93,3 +93,9 @@ jobs:
93
 
94
  - name: Build frontend
95
  run: npm run build
 
 
 
 
 
 
 
93
 
94
  - name: Build frontend
95
  run: npm run build
96
+
97
+ - name: Install Chromium
98
+ run: npx playwright install --with-deps chromium
99
+
100
+ - name: Run browser tests
101
+ run: npm run test:e2e
.gitignore CHANGED
@@ -251,3 +251,4 @@ workshop_AIE/
251
 
252
  # Local course-content working data (private Thinkific curriculum mapping + markdown) — never commit
253
  /full_stack_course/
 
 
251
 
252
  # Local course-content working data (private Thinkific curriculum mapping + markdown) — never commit
253
  /full_stack_course/
254
+ /output/playwright/
frontend/components/chat-message.test.tsx ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { ChatMessage } from "@/components/chat-message";
5
+ import type { TutorMessage, TutorMessagePart } from "@/lib/chat-ui";
6
+
7
+ afterEach(cleanup);
8
+
9
+ function assistantMessage(
10
+ parts: TutorMessagePart[],
11
+ id = "assistant-message",
12
+ ): TutorMessage {
13
+ return { id, role: "assistant", parts } as unknown as TutorMessage;
14
+ }
15
+
16
+ describe("ChatMessage activity rendering", () => {
17
+ it("preserves reasoning, tool, reasoning order and counts merged thoughts", () => {
18
+ render(
19
+ <ChatMessage
20
+ showAssistantActions={false}
21
+ message={assistantMessage([
22
+ { type: "reasoning", text: "First thought" },
23
+ { type: "reasoning", text: "Continuation of the first thought" },
24
+ {
25
+ type: "tool-retrieve_tutor_context",
26
+ toolCallId: "retrieve-1",
27
+ state: "output-available",
28
+ input: { query: "agent memory" },
29
+ output: {
30
+ matches: [
31
+ { docId: "doc-a", url: "https://example.com/a" },
32
+ { docId: "doc-b", url: "https://example.com/b" },
33
+ // Repeated evidence must not inflate the source count.
34
+ { docId: "doc-a", url: "https://example.com/a#section" },
35
+ ],
36
+ },
37
+ },
38
+ { type: "reasoning", text: "Second thought after retrieval" },
39
+ { type: "text", text: "Final answer" },
40
+ ])}
41
+ />,
42
+ );
43
+
44
+ const activityToggle = screen.getByRole("button", {
45
+ name: "Activity · 1 tool · 2 thoughts · 2 sources",
46
+ });
47
+ expect(activityToggle.getAttribute("aria-expanded")).toBe("false");
48
+
49
+ fireEvent.click(activityToggle);
50
+
51
+ const items = within(screen.getByRole("list")).getAllByRole("listitem");
52
+ expect(items).toHaveLength(3);
53
+ expect(items[0]?.textContent).toContain("First thought");
54
+ expect(items[0]?.textContent).toContain("Continuation of the first thought");
55
+ expect(items[1]?.textContent).toContain("Hybrid search");
56
+ expect(items[1]?.textContent).toContain("agent memory");
57
+ expect(items[2]?.textContent).toContain("Second thought after retrieval");
58
+ expect(screen.getByText("Final answer")).toBeTruthy();
59
+ });
60
+
61
+ it("shows Running only while a tool call is genuinely unfinished", () => {
62
+ const { rerender } = render(
63
+ <ChatMessage
64
+ isStreaming
65
+ showAssistantActions={false}
66
+ message={assistantMessage([
67
+ { type: "reasoning", text: "I should search first" },
68
+ {
69
+ type: "tool-retrieve_tutor_context",
70
+ toolCallId: "retrieve-1",
71
+ state: "input-available",
72
+ input: { query: "transformer attention" },
73
+ },
74
+ ])}
75
+ />,
76
+ );
77
+
78
+ expect(
79
+ screen.getByRole("button", {
80
+ name: /Running Hybrid search transformer attention/,
81
+ }),
82
+ ).toBeTruthy();
83
+
84
+ rerender(
85
+ <ChatMessage
86
+ isStreaming
87
+ showAssistantActions={false}
88
+ message={assistantMessage([
89
+ { type: "reasoning", text: "I should search first" },
90
+ {
91
+ type: "tool-retrieve_tutor_context",
92
+ toolCallId: "retrieve-1",
93
+ state: "output-available",
94
+ input: { query: "transformer attention" },
95
+ output: {
96
+ matches: [
97
+ { docId: "attention", url: "https://example.com/attention" },
98
+ ],
99
+ },
100
+ },
101
+ { type: "reasoning", text: "The result answers the question" },
102
+ ])}
103
+ />,
104
+ );
105
+
106
+ expect(
107
+ screen.getByRole("button", {
108
+ name: "Activity · 1 tool · 2 thoughts · 1 source",
109
+ }),
110
+ ).toBeTruthy();
111
+ expect(screen.queryByText("Running")).toBeNull();
112
+ expect(screen.getByText("The result answers the question")).toBeTruthy();
113
+ });
114
+
115
+ it("uses the web icon for provider-native search tools", () => {
116
+ render(
117
+ <ChatMessage
118
+ showAssistantActions={false}
119
+ message={assistantMessage([
120
+ {
121
+ type: "tool-google_search",
122
+ toolCallId: "search-1",
123
+ state: "output-available",
124
+ input: { query: "latest LangChain release" },
125
+ output: { text: "Search results" },
126
+ },
127
+ ])}
128
+ />,
129
+ );
130
+
131
+ fireEvent.click(screen.getByRole("button", { name: "Activity · 1 tool" }));
132
+ const toolItem = screen.getByRole("listitem");
133
+ expect(toolItem.querySelector(".lucide-globe")).not.toBeNull();
134
+ expect(toolItem.querySelector(".lucide-database")).toBeNull();
135
+ });
136
+
137
+ it("bounds expanded tool output by both line and character limits", () => {
138
+ const lineLimitedOutput = "one\ntwo\nthree\nfour\nfive\nsix";
139
+ const characterLimitedOutput = `${"x".repeat(1005)}TAIL`;
140
+
141
+ render(
142
+ <ChatMessage
143
+ showAssistantActions={false}
144
+ message={assistantMessage([
145
+ {
146
+ type: "tool-run_kb_command",
147
+ toolCallId: "kb-1",
148
+ state: "output-available",
149
+ input: { text: "head raw/docs/file.md" },
150
+ output: { text: lineLimitedOutput },
151
+ },
152
+ {
153
+ type: "tool-custom_reader",
154
+ toolCallId: "custom-1",
155
+ state: "output-available",
156
+ input: { text: "read the long result" },
157
+ output: { text: characterLimitedOutput },
158
+ },
159
+ ])}
160
+ />,
161
+ );
162
+
163
+ fireEvent.click(
164
+ screen.getByRole("button", { name: "Activity · 2 tools" }),
165
+ );
166
+ fireEvent.click(
167
+ screen.getByRole("button", { name: /KB shell.*6 lines/ }),
168
+ );
169
+
170
+ const lineTruncation = screen.getByText(/truncated \(4 chars more\)/);
171
+ const linePreview = lineTruncation.closest("pre");
172
+ expect(linePreview?.firstChild?.textContent).toBe(
173
+ "one\ntwo\nthree\nfour\nfive",
174
+ );
175
+ expect(linePreview?.textContent).not.toContain("six");
176
+
177
+ fireEvent.click(
178
+ screen.getByRole("button", { name: /Custom Reader.*1\.0k chars/ }),
179
+ );
180
+
181
+ const characterTruncation = screen.getByText(/truncated \(9 chars more\)/);
182
+ const characterPreview = characterTruncation.closest("pre");
183
+ expect(characterPreview?.firstChild?.textContent).toHaveLength(1000);
184
+ expect(characterPreview?.textContent).not.toContain("TAIL");
185
+ });
186
+ });
187
+
188
+ describe("ChatMessage citations", () => {
189
+ it("reuses an inline citation number on its source card and keeps unsafe links inert", () => {
190
+ const { container } = render(
191
+ <ChatMessage
192
+ showAssistantActions={false}
193
+ message={assistantMessage([
194
+ {
195
+ type: "text",
196
+ text: [
197
+ "[Trusted](https://example.com/docs#section)",
198
+ "[Again](https://example.com/docs/)",
199
+ "[Unsafe](javascript:alert(1))",
200
+ "[Missing KB file](raw/docs/missing.md)",
201
+ ].join(" "),
202
+ },
203
+ {
204
+ type: "data-source",
205
+ data: {
206
+ docId: "docs-1",
207
+ title: "Docs title",
208
+ url: "https://example.com/docs",
209
+ sourceKey: "example",
210
+ sourceLabel: "Example Docs",
211
+ score: 0.9,
212
+ group: "docs",
213
+ },
214
+ },
215
+ ])}
216
+ />,
217
+ );
218
+
219
+ const inlineChips = Array.from(
220
+ container.querySelectorAll<HTMLAnchorElement>("a.citation-chip"),
221
+ );
222
+ expect(inlineChips).toHaveLength(2);
223
+ expect(inlineChips.map((chip) => chip.textContent)).toEqual(["1", "1"]);
224
+
225
+ const sourceCard = screen.getByTitle("Docs · Example Docs");
226
+ expect(sourceCard.tagName).toBe("A");
227
+ expect(sourceCard.getAttribute("href")).toBe("https://example.com/docs");
228
+ expect(sourceCard.textContent).toContain("1");
229
+ expect(sourceCard.textContent).toContain("Docs title");
230
+
231
+ expect(screen.getByText("Unsafe").closest("a")).toBeNull();
232
+ expect(screen.getByText("Missing KB file").closest("a")).toBeNull();
233
+ });
234
+ });
frontend/components/chat-message.tsx CHANGED
@@ -574,12 +574,12 @@ function ToolKindIcon({ type, className }: { type: string; className?: string })
574
  if (name.includes("kb_command") || name.includes("shell") || name.includes("command")) {
575
  return <Terminal className={className} />;
576
  }
577
- if (name.includes("retrieve") || name.includes("search")) {
578
- return <Database className={className} />;
579
- }
580
  if (name.includes("web") || name.includes("url") || name.includes("google")) {
581
  return <Globe className={className} />;
582
  }
 
 
 
583
  return <Wrench className={className} />;
584
  }
585
 
 
574
  if (name.includes("kb_command") || name.includes("shell") || name.includes("command")) {
575
  return <Terminal className={className} />;
576
  }
 
 
 
577
  if (name.includes("web") || name.includes("url") || name.includes("google")) {
578
  return <Globe className={className} />;
579
  }
580
+ if (name.includes("retrieve") || name.includes("search")) {
581
+ return <Database className={className} />;
582
+ }
583
  return <Wrench className={className} />;
584
  }
585
 
frontend/components/chat-shell.test.tsx ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ const fetchToolsMock = vi.hoisted(() => vi.fn());
6
+
7
+ const chatHarness = vi.hoisted(() => ({
8
+ messages: [] as Array<{
9
+ id: string;
10
+ role: "assistant" | "user";
11
+ parts: Array<{ type: "text"; text: string }>;
12
+ }>,
13
+ status: "ready" as "error" | "ready" | "streaming" | "submitted",
14
+ error: null as Error | null,
15
+ onData: undefined as ((part: unknown) => void) | undefined,
16
+ sendMessage: vi.fn(),
17
+ setMessages: vi.fn(),
18
+ regenerate: vi.fn(),
19
+ stop: vi.fn(),
20
+ clearError: vi.fn(),
21
+ transportOptions: undefined as unknown,
22
+ }));
23
+
24
+ vi.mock("@/lib/api", async (importOriginal) => {
25
+ const original = await importOriginal<typeof import("@/lib/api")>();
26
+ return {
27
+ ...original,
28
+ fetchTools: fetchToolsMock,
29
+ getApiBaseUrl: () => "http://localhost:8000",
30
+ };
31
+ });
32
+
33
+ vi.mock("@ai-sdk/react", async () => {
34
+ const { useReducer } = await import("react");
35
+ return {
36
+ useChat: (options: { onData?: (part: unknown) => void }) => {
37
+ const [, forceRender] = useReducer((version: number) => version + 1, 0);
38
+ chatHarness.onData = options.onData;
39
+ return {
40
+ messages: chatHarness.messages,
41
+ sendMessage: chatHarness.sendMessage,
42
+ setMessages: (next: typeof chatHarness.messages) => {
43
+ chatHarness.setMessages(next);
44
+ forceRender();
45
+ },
46
+ regenerate: chatHarness.regenerate,
47
+ stop: chatHarness.stop,
48
+ status: chatHarness.status,
49
+ error: chatHarness.error,
50
+ clearError: chatHarness.clearError,
51
+ };
52
+ },
53
+ };
54
+ });
55
+
56
+ vi.mock("ai", () => ({
57
+ DefaultChatTransport: class DefaultChatTransport {
58
+ constructor(options: unknown) {
59
+ chatHarness.transportOptions = options;
60
+ }
61
+ },
62
+ }));
63
+
64
+ vi.mock("@/components/source-sidebar", () => ({
65
+ SourceSidebar: ({
66
+ onNewChat,
67
+ onToggleSource,
68
+ onToggleTool,
69
+ sourceError,
70
+ }: {
71
+ onNewChat: () => void;
72
+ onToggleSource: (key: string) => void;
73
+ onToggleTool: (key: string) => void;
74
+ sourceError: string | null;
75
+ }) => (
76
+ <aside>
77
+ <button type="button" onClick={onNewChat}>
78
+ New chat
79
+ </button>
80
+ <button type="button" onClick={() => onToggleSource("course-default")}>
81
+ Toggle default course
82
+ </button>
83
+ <button type="button" onClick={() => onToggleSource("docs-optional")}>
84
+ Toggle optional docs
85
+ </button>
86
+ <button type="button" onClick={() => onToggleTool("web_search")}>
87
+ Toggle web search
88
+ </button>
89
+ {sourceError ? <p role="alert">{sourceError}</p> : null}
90
+ </aside>
91
+ ),
92
+ }));
93
+
94
+ vi.mock("@/components/chat-message", () => ({
95
+ ChatMessage: ({
96
+ message,
97
+ isEditing,
98
+ editDraft,
99
+ onAssistantRedo,
100
+ onEditChange,
101
+ onEditSave,
102
+ onUserEdit,
103
+ }: {
104
+ message: {
105
+ id: string;
106
+ role: "assistant" | "user";
107
+ parts: Array<{ type: string; text?: string }>;
108
+ };
109
+ isEditing: boolean;
110
+ editDraft: string;
111
+ onAssistantRedo: (messageId: string) => void;
112
+ onEditChange: (value: string) => void;
113
+ onEditSave: (messageId: string) => void;
114
+ onUserEdit: (messageId: string) => void;
115
+ }) => {
116
+ const text = message.parts
117
+ .filter((part) => part.type === "text")
118
+ .map((part) => part.text ?? "")
119
+ .join("");
120
+
121
+ return (
122
+ <div data-testid={`message-${message.id}`}>
123
+ {isEditing ? (
124
+ <>
125
+ <textarea
126
+ aria-label={`Edit ${message.id}`}
127
+ value={editDraft}
128
+ onChange={(event) => onEditChange(event.target.value)}
129
+ />
130
+ <button type="button" onClick={() => onEditSave(message.id)}>
131
+ Save {message.id}
132
+ </button>
133
+ </>
134
+ ) : (
135
+ <span>{text}</span>
136
+ )}
137
+ {message.role === "user" && !isEditing ? (
138
+ <button type="button" onClick={() => onUserEdit(message.id)}>
139
+ Edit {message.id}
140
+ </button>
141
+ ) : null}
142
+ {message.role === "assistant" ? (
143
+ <button type="button" onClick={() => onAssistantRedo(message.id)}>
144
+ Redo {message.id}
145
+ </button>
146
+ ) : null}
147
+ </div>
148
+ );
149
+ },
150
+ }));
151
+
152
+ import { ChatShell } from "@/components/chat-shell";
153
+
154
+ const DEFAULT_MODEL = "deepseek:deepseek-v4-flash";
155
+ const CLAUDE_MODEL = "anthropic:claude-haiku-4-5";
156
+
157
+ const toolsResponse = {
158
+ model: DEFAULT_MODEL,
159
+ availableModels: [
160
+ { id: DEFAULT_MODEL, label: "DeepSeek V4 Flash" },
161
+ { id: CLAUDE_MODEL, label: "Claude Haiku 4.5" },
162
+ ],
163
+ tools: [
164
+ {
165
+ kind: "configurable" as const,
166
+ key: "retrieval" as const,
167
+ label: "Knowledge base",
168
+ active: true,
169
+ sources: [
170
+ {
171
+ key: "course-default",
172
+ label: "Default course",
173
+ shortLabel: "Course",
174
+ group: "courses" as const,
175
+ selectedByDefault: true,
176
+ },
177
+ {
178
+ key: "docs-optional",
179
+ label: "Optional docs",
180
+ shortLabel: "Docs",
181
+ group: "docs" as const,
182
+ selectedByDefault: false,
183
+ },
184
+ ],
185
+ },
186
+ {
187
+ kind: "toggle" as const,
188
+ key: "web_search",
189
+ label: "Web search",
190
+ active: true,
191
+ },
192
+ {
193
+ kind: "toggle" as const,
194
+ key: "url_context",
195
+ label: "URL context",
196
+ active: false,
197
+ },
198
+ ],
199
+ };
200
+
201
+ function message(
202
+ id: string,
203
+ role: "assistant" | "user",
204
+ text: string,
205
+ ) {
206
+ return { id, role, parts: [{ type: "text" as const, text }] };
207
+ }
208
+
209
+ function deferred<T>() {
210
+ let resolve!: (value: T) => void;
211
+ let reject!: (reason?: unknown) => void;
212
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
213
+ resolve = resolvePromise;
214
+ reject = rejectPromise;
215
+ });
216
+ return { promise, reject, resolve };
217
+ }
218
+
219
+ function sentBody(callIndex = 0) {
220
+ return chatHarness.sendMessage.mock.calls[callIndex]?.[1]?.body;
221
+ }
222
+
223
+ beforeEach(() => {
224
+ chatHarness.messages = [];
225
+ chatHarness.status = "ready";
226
+ chatHarness.error = null;
227
+ chatHarness.onData = undefined;
228
+ chatHarness.transportOptions = undefined;
229
+ chatHarness.sendMessage.mockReset();
230
+ chatHarness.regenerate.mockReset();
231
+ chatHarness.stop.mockReset();
232
+ chatHarness.clearError.mockReset();
233
+ chatHarness.setMessages.mockReset();
234
+ chatHarness.setMessages.mockImplementation(
235
+ (next: typeof chatHarness.messages) => {
236
+ chatHarness.messages = next;
237
+ },
238
+ );
239
+ fetchToolsMock.mockReset();
240
+ fetchToolsMock.mockResolvedValue(toolsResponse);
241
+ });
242
+
243
+ describe("ChatShell request lifecycle", () => {
244
+ it("keeps Send disabled until the initial tool and model defaults are ready", async () => {
245
+ const pendingTools = deferred<typeof toolsResponse>();
246
+ fetchToolsMock.mockReturnValueOnce(pendingTools.promise);
247
+ const user = userEvent.setup();
248
+
249
+ render(<ChatShell />);
250
+ await user.type(screen.getByRole("textbox"), "What is RAG?");
251
+
252
+ expect(
253
+ (screen.getByRole("button", { name: "Send message" }) as HTMLButtonElement)
254
+ .disabled,
255
+ ).toBe(true);
256
+ await user.keyboard("{Enter}");
257
+ const suggestion = screen
258
+ .getByRole("heading", { name: "Ask your AI tutor" })
259
+ .parentElement?.querySelector("button");
260
+ expect(suggestion).not.toBeNull();
261
+ await user.click(suggestion!);
262
+ expect(chatHarness.sendMessage).not.toHaveBeenCalled();
263
+
264
+ await act(async () => pendingTools.resolve(toolsResponse));
265
+
266
+ await waitFor(() =>
267
+ expect(
268
+ (screen.getByRole("button", {
269
+ name: "Send message",
270
+ }) as HTMLButtonElement).disabled,
271
+ ).toBe(false),
272
+ );
273
+ });
274
+
275
+ it("submits the loaded model, default sources, and active tools", async () => {
276
+ const user = userEvent.setup();
277
+ render(<ChatShell />);
278
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" });
279
+
280
+ await user.type(screen.getByRole("textbox"), " Explain agents ");
281
+ await user.click(screen.getByRole("button", { name: "Send message" }));
282
+
283
+ expect(chatHarness.sendMessage).toHaveBeenCalledWith(
284
+ { text: "Explain agents" },
285
+ {
286
+ body: {
287
+ sourceKeys: ["course-default"],
288
+ enabledTools: ["web_search"],
289
+ includeReasoning: true,
290
+ threadId: "",
291
+ model: DEFAULT_MODEL,
292
+ },
293
+ },
294
+ );
295
+ });
296
+
297
+ it("sends the user's current source and tool selections, including no sources", async () => {
298
+ const user = userEvent.setup();
299
+ render(<ChatShell />);
300
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" });
301
+
302
+ await user.click(screen.getByRole("button", { name: "Toggle optional docs" }));
303
+ await user.click(screen.getByRole("button", { name: "Toggle web search" }));
304
+ await user.type(screen.getByRole("textbox"), "Selected sources");
305
+ await user.click(screen.getByRole("button", { name: "Send message" }));
306
+
307
+ expect(sentBody()).toMatchObject({
308
+ sourceKeys: ["course-default", "docs-optional"],
309
+ enabledTools: [],
310
+ });
311
+
312
+ await user.click(screen.getByRole("button", { name: "Toggle default course" }));
313
+ await user.click(screen.getByRole("button", { name: "Toggle optional docs" }));
314
+ await user.type(screen.getByRole("textbox"), "No knowledge base");
315
+ await user.click(screen.getByRole("button", { name: "Send message" }));
316
+
317
+ expect(sentBody(1)).toMatchObject({ sourceKeys: [], enabledTools: [] });
318
+ });
319
+
320
+ it("adopts data-thread immediately and sends it with the next turn", async () => {
321
+ const user = userEvent.setup();
322
+ render(<ChatShell />);
323
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" });
324
+
325
+ await user.type(screen.getByRole("textbox"), "First question");
326
+ await user.click(screen.getByRole("button", { name: "Send message" }));
327
+ act(() => {
328
+ chatHarness.onData?.({
329
+ type: "data-thread",
330
+ data: { threadId: "thread-from-server" },
331
+ });
332
+ });
333
+ await user.type(screen.getByRole("textbox"), "Follow up");
334
+ await user.click(screen.getByRole("button", { name: "Send message" }));
335
+
336
+ expect(sentBody(1)).toMatchObject({ threadId: "thread-from-server" });
337
+
338
+ act(() => {
339
+ chatHarness.onData?.({
340
+ type: "data-thread",
341
+ data: { threadId: "replacement-thread" },
342
+ });
343
+ });
344
+ await user.type(screen.getByRole("textbox"), "One more turn");
345
+ await user.click(screen.getByRole("button", { name: "Send message" }));
346
+
347
+ expect(sentBody(2)).toMatchObject({ threadId: "replacement-thread" });
348
+ });
349
+
350
+ it("locks model switching for an existing conversation and unlocks on New chat", async () => {
351
+ chatHarness.messages = [message("u1", "user", "First question")];
352
+ const user = userEvent.setup();
353
+ render(<ChatShell />);
354
+
355
+ const modelPicker = await screen.findByRole("button", {
356
+ name: "DeepSeek V4 Flash",
357
+ });
358
+ expect((modelPicker as HTMLButtonElement).disabled).toBe(true);
359
+
360
+ await user.click(screen.getByRole("button", { name: "New chat" }));
361
+
362
+ expect(chatHarness.setMessages).toHaveBeenCalledWith([]);
363
+ await waitFor(() =>
364
+ expect((modelPicker as HTMLButtonElement).disabled).toBe(false),
365
+ );
366
+ });
367
+
368
+ it("clears a failed model-specific tools request after a successful retry", async () => {
369
+ fetchToolsMock
370
+ .mockResolvedValueOnce(toolsResponse)
371
+ .mockRejectedValueOnce(new Error("Unable to load Claude tools"))
372
+ .mockResolvedValueOnce(toolsResponse);
373
+ const user = userEvent.setup();
374
+ render(<ChatShell />);
375
+
376
+ await user.click(
377
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" }),
378
+ );
379
+ await user.click(screen.getByRole("option", { name: "Claude Haiku 4.5" }));
380
+ expect(await screen.findByRole("alert")).toHaveTextContent(
381
+ "Unable to load Claude tools",
382
+ );
383
+
384
+ await user.click(screen.getByRole("button", { name: "Claude Haiku 4.5" }));
385
+ await user.click(screen.getByRole("option", { name: "DeepSeek V4 Flash" }));
386
+
387
+ await waitFor(() => expect(fetchToolsMock).toHaveBeenCalledTimes(3));
388
+ await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
389
+ });
390
+
391
+ it("pauses submissions while model-specific tools are reloading", async () => {
392
+ const pendingClaudeTools = deferred<typeof toolsResponse>();
393
+ fetchToolsMock
394
+ .mockResolvedValueOnce(toolsResponse)
395
+ .mockReturnValueOnce(pendingClaudeTools.promise);
396
+ const user = userEvent.setup();
397
+ render(<ChatShell />);
398
+
399
+ await user.click(
400
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" }),
401
+ );
402
+ await user.click(screen.getByRole("option", { name: "Claude Haiku 4.5" }));
403
+ await waitFor(() => expect(fetchToolsMock).toHaveBeenCalledTimes(2));
404
+
405
+ await user.type(screen.getByRole("textbox"), "Compare the providers");
406
+ await waitFor(() =>
407
+ expect(
408
+ (screen.getByRole("button", {
409
+ name: "Send message",
410
+ }) as HTMLButtonElement).disabled,
411
+ ).toBe(true),
412
+ );
413
+ await user.keyboard("{Enter}");
414
+ expect(chatHarness.sendMessage).not.toHaveBeenCalled();
415
+
416
+ await act(async () => pendingClaudeTools.resolve(toolsResponse));
417
+ await waitFor(() =>
418
+ expect(
419
+ (screen.getByRole("button", {
420
+ name: "Send message",
421
+ }) as HTMLButtonElement).disabled,
422
+ ).toBe(false),
423
+ );
424
+ await user.click(screen.getByRole("button", { name: "Send message" }));
425
+
426
+ expect(sentBody()).toMatchObject({ model: CLAUDE_MODEL });
427
+ });
428
+
429
+ it("keeps the registry ready when the active model is selected again", async () => {
430
+ const user = userEvent.setup();
431
+ render(<ChatShell />);
432
+
433
+ await user.click(
434
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" }),
435
+ );
436
+ await user.click(screen.getByRole("option", { name: "DeepSeek V4 Flash" }));
437
+ expect(fetchToolsMock).toHaveBeenCalledOnce();
438
+
439
+ await user.type(screen.getByRole("textbox"), "Still ready");
440
+ const sendButton = screen.getByRole("button", { name: "Send message" });
441
+ expect((sendButton as HTMLButtonElement).disabled).toBe(false);
442
+ await user.click(sendButton);
443
+ expect(chatHarness.sendMessage).toHaveBeenCalledOnce();
444
+ });
445
+
446
+ it("clears a previous stream error and allows a retry", async () => {
447
+ chatHarness.status = "error";
448
+ chatHarness.error = new Error("The previous request failed");
449
+ const user = userEvent.setup();
450
+ render(<ChatShell />);
451
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" });
452
+
453
+ expect(screen.getByText("The previous request failed")).toBeVisible();
454
+ await user.type(screen.getByRole("textbox"), "Try again");
455
+ await user.click(screen.getByRole("button", { name: "Send message" }));
456
+
457
+ expect(chatHarness.clearError).toHaveBeenCalled();
458
+ expect(chatHarness.sendMessage).toHaveBeenCalledWith(
459
+ { text: "Try again" },
460
+ expect.objectContaining({ body: expect.any(Object) }),
461
+ );
462
+ });
463
+
464
+ it.each(["submitted", "streaming"] as const)(
465
+ "offers Stop and aborts while the request is %s",
466
+ async (status) => {
467
+ chatHarness.status = status;
468
+ const user = userEvent.setup();
469
+ render(<ChatShell />);
470
+
471
+ await user.click(screen.getByRole("button", { name: "Stop generating" }));
472
+
473
+ expect(chatHarness.stop).toHaveBeenCalledTimes(1);
474
+ expect(chatHarness.sendMessage).not.toHaveBeenCalled();
475
+ },
476
+ );
477
+
478
+ it("passes the selected message boundary to edit and regenerate", async () => {
479
+ chatHarness.messages = [
480
+ message("u1", "user", "Original question"),
481
+ message("a1", "assistant", "First answer"),
482
+ message("u2", "user", "Later question"),
483
+ message("a2", "assistant", "Later answer"),
484
+ ];
485
+ const user = userEvent.setup();
486
+ render(<ChatShell />);
487
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" });
488
+
489
+ await user.click(screen.getByRole("button", { name: "Redo a1" }));
490
+ expect(chatHarness.regenerate).toHaveBeenCalledWith({
491
+ messageId: "a1",
492
+ body: expect.objectContaining({
493
+ model: DEFAULT_MODEL,
494
+ threadId: "",
495
+ }),
496
+ });
497
+
498
+ await user.click(screen.getByRole("button", { name: "Edit u1" }));
499
+ const editor = screen.getByRole("textbox", { name: "Edit u1" });
500
+ fireEvent.change(editor, { target: { value: "Rewritten question" } });
501
+ await user.click(screen.getByRole("button", { name: "Save u1" }));
502
+
503
+ expect(chatHarness.sendMessage).toHaveBeenCalledWith(
504
+ { text: "Rewritten question", messageId: "u1" },
505
+ {
506
+ body: expect.objectContaining({
507
+ model: DEFAULT_MODEL,
508
+ threadId: "",
509
+ }),
510
+ },
511
+ );
512
+ });
513
+
514
+ it("clears an adopted thread when starting a new chat", async () => {
515
+ const user = userEvent.setup();
516
+ render(<ChatShell />);
517
+ await screen.findByRole("button", { name: "DeepSeek V4 Flash" });
518
+
519
+ await user.type(screen.getByRole("textbox"), "First question");
520
+ await user.click(screen.getByRole("button", { name: "Send message" }));
521
+ act(() => {
522
+ chatHarness.onData?.({
523
+ type: "data-thread",
524
+ data: { threadId: "old-thread" },
525
+ });
526
+ });
527
+ await user.click(screen.getByRole("button", { name: "New chat" }));
528
+ await user.type(screen.getByRole("textbox"), "Fresh question");
529
+ await user.click(screen.getByRole("button", { name: "Send message" }));
530
+
531
+ expect(sentBody(1)).toMatchObject({ threadId: "" });
532
+ });
533
+
534
+ it("ignores a late thread event from a stream abandoned by New chat", async () => {
535
+ chatHarness.status = "streaming";
536
+ const user = userEvent.setup();
537
+ render(<ChatShell />);
538
+ await waitFor(() => expect(fetchToolsMock).toHaveBeenCalledOnce());
539
+
540
+ await user.click(screen.getByRole("button", { name: "New chat" }));
541
+ expect(chatHarness.stop).toHaveBeenCalledOnce();
542
+
543
+ act(() => {
544
+ chatHarness.onData?.({
545
+ type: "data-thread",
546
+ data: { threadId: "late-abandoned-thread" },
547
+ });
548
+ });
549
+
550
+ chatHarness.status = "ready";
551
+ await user.type(screen.getByRole("textbox"), "Fresh after abort");
552
+ await user.click(screen.getByRole("button", { name: "Send message" }));
553
+
554
+ expect(sentBody()).toMatchObject({ threadId: "" });
555
+ });
556
+ });
frontend/components/chat-shell.tsx CHANGED
@@ -75,6 +75,7 @@ export function ChatShell() {
75
  const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
76
  const [enabledToolKeys, setEnabledToolKeys] = useState<string[]>([]);
77
  const [sourceError, setSourceError] = useState<string | null>(null);
 
78
  const [input, setInput] = useState("");
79
  const [threadId, setThreadId] = useState("");
80
  const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
@@ -84,6 +85,7 @@ export function ChatShell() {
84
  const composerInputRef = useRef<HTMLTextAreaElement>(null);
85
  const pendingScrollRef = useRef(false);
86
  const spacerRef = useRef<HTMLDivElement>(null);
 
87
 
88
  const {
89
  messages,
@@ -97,7 +99,7 @@ export function ChatShell() {
97
  } = useChat({
98
  transport,
99
  onData: (part) => {
100
- if (part.type === "data-thread") {
101
  const nextThreadId = (part as ThreadDataPart).data.threadId;
102
  if (nextThreadId) {
103
  // Urgent update on purpose: deferring it (startTransition) lets a
@@ -141,6 +143,7 @@ export function ChatShell() {
141
  .map((tool) => tool.key),
142
  );
143
  setSourceError(null);
 
144
  } catch (loadError) {
145
  if (controller.signal.aborted) {
146
  return;
@@ -150,6 +153,7 @@ export function ChatShell() {
150
  ? loadError.message
151
  : "Unable to load tool registry.",
152
  );
 
153
  }
154
  }
155
 
@@ -179,6 +183,8 @@ export function ChatShell() {
179
  .filter((tool) => tool.kind === "toggle" && tool.active)
180
  .map((tool) => tool.key),
181
  );
 
 
182
  } catch (loadError) {
183
  if (controller.signal.aborted) {
184
  return;
@@ -188,6 +194,7 @@ export function ChatShell() {
188
  ? loadError.message
189
  : "Unable to load tool registry.",
190
  );
 
191
  }
192
  }
193
 
@@ -199,7 +206,8 @@ export function ChatShell() {
199
  // After a stream error the SDK parks status on "error" until clearError()
200
  // or the next request; handleSubmit clears it, so the error state must
201
  // stay sendable or the Send button locks out mouse users for good.
202
- const canSend = status === "ready" || status === "error";
 
203
  const typedMessages = messages as TutorMessage[];
204
  const latestMessage = typedMessages[typedMessages.length - 1];
205
  const streamingAssistantId =
@@ -282,11 +290,12 @@ export function ChatShell() {
282
 
283
  async function handleSubmit(override?: string) {
284
  const trimmed = (override ?? input).trim();
285
- if (!trimmed || isStreaming) {
286
  return;
287
  }
288
 
289
  clearError();
 
290
  setCopiedMessageId(null);
291
  setEditingMessageId(null);
292
  setEditingText("");
@@ -307,7 +316,11 @@ export function ChatShell() {
307
  }
308
 
309
  async function handleRedo(messageId?: string) {
 
 
 
310
  clearError();
 
311
  setCopiedMessageId(null);
312
  await regenerate({
313
  messageId,
@@ -362,11 +375,12 @@ export function ChatShell() {
362
 
363
  async function handleEditSave(messageId: string) {
364
  const trimmed = editingText.trim();
365
- if (!trimmed || isStreaming) {
366
  return;
367
  }
368
 
369
  clearError();
 
370
  setCopiedMessageId(null);
371
  setEditingMessageId(null);
372
  setEditingText("");
@@ -386,6 +400,7 @@ export function ChatShell() {
386
  }
387
 
388
  function handleNewChat() {
 
389
  if (isStreaming) {
390
  stop();
391
  }
@@ -500,7 +515,13 @@ export function ChatShell() {
500
  <ModelPicker
501
  availableModels={availableModels}
502
  locked={typedMessages.length > 0}
503
- onSelect={setSelectedModel}
 
 
 
 
 
 
504
  selectedModel={selectedModel}
505
  />
506
  <ComposerActionButton
 
75
  const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
76
  const [enabledToolKeys, setEnabledToolKeys] = useState<string[]>([]);
77
  const [sourceError, setSourceError] = useState<string | null>(null);
78
+ const [toolRegistryReady, setToolRegistryReady] = useState(false);
79
  const [input, setInput] = useState("");
80
  const [threadId, setThreadId] = useState("");
81
  const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
 
85
  const composerInputRef = useRef<HTMLTextAreaElement>(null);
86
  const pendingScrollRef = useRef(false);
87
  const spacerRef = useRef<HTMLDivElement>(null);
88
+ const acceptThreadDataRef = useRef(false);
89
 
90
  const {
91
  messages,
 
99
  } = useChat({
100
  transport,
101
  onData: (part) => {
102
+ if (part.type === "data-thread" && acceptThreadDataRef.current) {
103
  const nextThreadId = (part as ThreadDataPart).data.threadId;
104
  if (nextThreadId) {
105
  // Urgent update on purpose: deferring it (startTransition) lets a
 
143
  .map((tool) => tool.key),
144
  );
145
  setSourceError(null);
146
+ setToolRegistryReady(true);
147
  } catch (loadError) {
148
  if (controller.signal.aborted) {
149
  return;
 
153
  ? loadError.message
154
  : "Unable to load tool registry.",
155
  );
156
+ setToolRegistryReady(false);
157
  }
158
  }
159
 
 
183
  .filter((tool) => tool.kind === "toggle" && tool.active)
184
  .map((tool) => tool.key),
185
  );
186
+ setSourceError(null);
187
+ setToolRegistryReady(true);
188
  } catch (loadError) {
189
  if (controller.signal.aborted) {
190
  return;
 
194
  ? loadError.message
195
  : "Unable to load tool registry.",
196
  );
197
+ setToolRegistryReady(false);
198
  }
199
  }
200
 
 
206
  // After a stream error the SDK parks status on "error" until clearError()
207
  // or the next request; handleSubmit clears it, so the error state must
208
  // stay sendable or the Send button locks out mouse users for good.
209
+ const canSend =
210
+ toolRegistryReady && (status === "ready" || status === "error");
211
  const typedMessages = messages as TutorMessage[];
212
  const latestMessage = typedMessages[typedMessages.length - 1];
213
  const streamingAssistantId =
 
290
 
291
  async function handleSubmit(override?: string) {
292
  const trimmed = (override ?? input).trim();
293
+ if (!trimmed || !canSend) {
294
  return;
295
  }
296
 
297
  clearError();
298
+ acceptThreadDataRef.current = true;
299
  setCopiedMessageId(null);
300
  setEditingMessageId(null);
301
  setEditingText("");
 
316
  }
317
 
318
  async function handleRedo(messageId?: string) {
319
+ if (!canSend) {
320
+ return;
321
+ }
322
  clearError();
323
+ acceptThreadDataRef.current = true;
324
  setCopiedMessageId(null);
325
  await regenerate({
326
  messageId,
 
375
 
376
  async function handleEditSave(messageId: string) {
377
  const trimmed = editingText.trim();
378
+ if (!trimmed || !canSend) {
379
  return;
380
  }
381
 
382
  clearError();
383
+ acceptThreadDataRef.current = true;
384
  setCopiedMessageId(null);
385
  setEditingMessageId(null);
386
  setEditingText("");
 
400
  }
401
 
402
  function handleNewChat() {
403
+ acceptThreadDataRef.current = false;
404
  if (isStreaming) {
405
  stop();
406
  }
 
515
  <ModelPicker
516
  availableModels={availableModels}
517
  locked={typedMessages.length > 0}
518
+ onSelect={(modelId) => {
519
+ if (modelId === selectedModel) {
520
+ return;
521
+ }
522
+ setToolRegistryReady(false);
523
+ setSelectedModel(modelId);
524
+ }}
525
  selectedModel={selectedModel}
526
  />
527
  <ComposerActionButton
frontend/components/markdown-block.test.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { MarkdownBlock } from "@/components/markdown-block";
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe("MarkdownBlock links", () => {
9
+ it("renders short numbered links as chips but retains long claim text", () => {
10
+ const url = "https://example.com/reference";
11
+ const longClaim =
12
+ "This linked sentence is a substantive claim that should stay readable in the answer";
13
+
14
+ render(
15
+ <MarkdownBlock citationNumbers={new Map([[url, 1]])}>
16
+ {`[Short](${url}) and [${longClaim}](${url})`}
17
+ </MarkdownBlock>,
18
+ );
19
+
20
+ const chips = screen.getAllByRole("link", { name: "1" });
21
+ expect(chips).toHaveLength(2);
22
+ expect(chips.every((chip) => chip.getAttribute("href") === url)).toBe(true);
23
+ expect(screen.queryByText("Short")).toBeNull();
24
+ expect(screen.getByText(longClaim)).toBeTruthy();
25
+ });
26
+
27
+ it("allows external HTTP and email links while leaving unsafe and relative links inert", () => {
28
+ render(
29
+ <MarkdownBlock>
30
+ {
31
+ "[Website](https://safe.example/path) [Email](mailto:tutor@example.com) [Unsafe](javascript:alert(1)) [Local](raw/docs/private.md)"
32
+ }
33
+ </MarkdownBlock>,
34
+ );
35
+
36
+ const website = screen.getByRole("link", { name: "Website" });
37
+ expect(website.getAttribute("href")).toBe("https://safe.example/path");
38
+ expect(website.getAttribute("target")).toBe("_blank");
39
+ expect(website.getAttribute("rel")).toBe("noreferrer");
40
+
41
+ const email = screen.getByRole("link", { name: "Email" });
42
+ expect(email.getAttribute("href")).toBe("mailto:tutor@example.com");
43
+ expect(email.getAttribute("target")).toBe("_blank");
44
+ expect(email.getAttribute("rel")).toBe("noreferrer");
45
+
46
+ expect(screen.getByText("Unsafe").closest("a")).toBeNull();
47
+ expect(screen.getByText("Local").closest("a")).toBeNull();
48
+ });
49
+
50
+ it("resolves KB references before rendering and gives equivalent references the same number", () => {
51
+ const resolvedUrl = "https://docs.example.com/guide";
52
+ const citationNumbers = new Map([[resolvedUrl, 7]]);
53
+ const citationResolutions = new Map([
54
+ ["raw/docs/guide.md", resolvedUrl],
55
+ ["kb://doc/guide-id", resolvedUrl],
56
+ ]);
57
+
58
+ render(
59
+ <MarkdownBlock
60
+ citationNumbers={citationNumbers}
61
+ citationResolutions={citationResolutions}
62
+ >
63
+ {"[Raw path](raw/docs/guide.md) [Document ID](kb://doc/guide-id)"}
64
+ </MarkdownBlock>,
65
+ );
66
+
67
+ const chips = screen.getAllByRole("link", { name: "7" });
68
+ expect(chips).toHaveLength(2);
69
+ expect(chips.map((chip) => chip.getAttribute("href"))).toEqual([
70
+ resolvedUrl,
71
+ resolvedUrl,
72
+ ]);
73
+ expect(chips.map((chip) => chip.getAttribute("title"))).toEqual([
74
+ "Raw path",
75
+ "Document ID",
76
+ ]);
77
+ });
78
+ });
frontend/components/source-sidebar.test.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { SourceSidebar } from "@/components/source-sidebar";
5
+ import type { TutorSource, TutorTool } from "@/lib/api";
6
+
7
+ afterEach(cleanup);
8
+
9
+ const sources: TutorSource[] = [
10
+ {
11
+ key: "course-a",
12
+ label: "Course A",
13
+ shortLabel: "Course A",
14
+ description: "Learn practical agent engineering.",
15
+ infoUrl: "https://academy.example.com/course-a",
16
+ group: "courses",
17
+ selectedByDefault: true,
18
+ },
19
+ {
20
+ key: "docs-a",
21
+ label: "Documentation A",
22
+ shortLabel: "Docs A",
23
+ description: "Reference documentation for the library.",
24
+ infoUrl: "https://docs.example.com/",
25
+ group: "docs",
26
+ selectedByDefault: false,
27
+ version: "v1.2",
28
+ indexedAt: "2026-07-01",
29
+ },
30
+ ];
31
+
32
+ const tools: TutorTool[] = [
33
+ {
34
+ kind: "configurable",
35
+ key: "retrieval",
36
+ label: "Knowledge base",
37
+ active: true,
38
+ sources,
39
+ },
40
+ {
41
+ kind: "toggle",
42
+ key: "web_search",
43
+ label: "Web search",
44
+ active: false,
45
+ },
46
+ ];
47
+
48
+ function renderSidebar() {
49
+ const handlers = {
50
+ onNewChat: vi.fn(),
51
+ onToggleSource: vi.fn(),
52
+ onToggleTool: vi.fn(),
53
+ };
54
+ render(
55
+ <SourceSidebar
56
+ {...handlers}
57
+ selectedSourceKeys={["course-a"]}
58
+ enabledToolKeys={["web_search"]}
59
+ sourceError={null}
60
+ tools={tools}
61
+ />,
62
+ );
63
+ return handlers;
64
+ }
65
+
66
+ describe("SourceSidebar", () => {
67
+ it("reports active tools and routes new-chat, source, and tool actions", () => {
68
+ const handlers = renderSidebar();
69
+
70
+ expect(screen.getByText("2/2")).toBeTruthy();
71
+ expect(
72
+ screen.getByRole("button", { name: "Course A" }).getAttribute(
73
+ "aria-pressed",
74
+ ),
75
+ ).toBe("true");
76
+ expect(
77
+ screen.getByRole("button", { name: "Docs A" }).getAttribute(
78
+ "aria-pressed",
79
+ ),
80
+ ).toBe("false");
81
+ expect(
82
+ screen.getByRole("button", { name: "Web search" }).getAttribute(
83
+ "aria-pressed",
84
+ ),
85
+ ).toBe("true");
86
+
87
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
88
+ fireEvent.click(screen.getByRole("button", { name: "Docs A" }));
89
+ fireEvent.click(screen.getByRole("button", { name: "Web search" }));
90
+
91
+ expect(handlers.onNewChat).toHaveBeenCalledOnce();
92
+ expect(handlers.onToggleSource).toHaveBeenCalledWith("docs-a");
93
+ expect(handlers.onToggleTool).toHaveBeenCalledWith("web_search");
94
+ });
95
+
96
+ it("shows registry metadata in a portaled source popover and closes it with Escape", () => {
97
+ renderSidebar();
98
+
99
+ const infoButton = screen.getByRole("button", {
100
+ name: "About Documentation A",
101
+ });
102
+ expect(infoButton.getAttribute("aria-expanded")).toBe("false");
103
+
104
+ fireEvent.click(infoButton);
105
+
106
+ const dialog = screen.getByRole("dialog");
107
+ expect(dialog.textContent).toContain(
108
+ "Reference documentation for the library.",
109
+ );
110
+ expect(dialog.textContent).toContain("v1.2 · indexed Jul 2026");
111
+ const docsLink = screen.getByRole("link", { name: /View docs/ });
112
+ expect(docsLink.getAttribute("href")).toBe("https://docs.example.com/");
113
+ expect(docsLink.getAttribute("target")).toBe("_blank");
114
+
115
+ fireEvent.keyDown(document, { key: "Escape" });
116
+ expect(screen.queryByRole("dialog")).toBeNull();
117
+ expect(infoButton.getAttribute("aria-expanded")).toBe("false");
118
+ });
119
+ });
frontend/components/source-sidebar.tsx CHANGED
@@ -480,7 +480,11 @@ function formatIndexedMonth(isoDate: string | null | undefined): string | null {
480
  if (!isoDate) return null;
481
  const date = new Date(isoDate);
482
  if (Number.isNaN(date.getTime())) return null;
483
- return date.toLocaleString("en-US", { month: "short", year: "numeric" });
 
 
 
 
484
  }
485
 
486
  function getPopoverInfo(source: TutorSource): PopoverInfo | undefined {
 
480
  if (!isoDate) return null;
481
  const date = new Date(isoDate);
482
  if (Number.isNaN(date.getTime())) return null;
483
+ return date.toLocaleString("en-US", {
484
+ month: "short",
485
+ year: "numeric",
486
+ timeZone: "UTC",
487
+ });
488
  }
489
 
490
  function getPopoverInfo(source: TutorSource): PopoverInfo | undefined {
frontend/e2e/chat-flow.spec.ts ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test";
2
+
3
+ const MODEL = "deepseek:deepseek-v4-flash";
4
+ const largeToolOutput = "retrieved context ".repeat(20_000);
5
+
6
+ const toolsResponse = {
7
+ model: MODEL,
8
+ availableModels: [{ id: MODEL, label: "DeepSeek V4 Flash" }],
9
+ tools: [
10
+ {
11
+ kind: "configurable",
12
+ key: "retrieval",
13
+ label: "Knowledge base",
14
+ active: true,
15
+ sources: [
16
+ {
17
+ key: "example-docs",
18
+ label: "Example documentation",
19
+ shortLabel: "Example docs",
20
+ description: "Documentation used by the browser test.",
21
+ infoUrl: "https://example.test/docs",
22
+ group: "docs",
23
+ selectedByDefault: true,
24
+ version: "v1.0",
25
+ indexedAt: "2026-07-01",
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ };
31
+
32
+ function sse(parts: Array<Record<string, unknown>>) {
33
+ return `${parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("")}data: [DONE]\n\n`;
34
+ }
35
+
36
+ async function mockTools(page: Page) {
37
+ await page.route("**/api/tools**", (route) =>
38
+ route.fulfill({
39
+ status: 200,
40
+ contentType: "application/json",
41
+ body: JSON.stringify(toolsResponse),
42
+ }),
43
+ );
44
+ }
45
+
46
+ test("streams activity and sends a compact follow-up on the same thread", async ({
47
+ page,
48
+ }) => {
49
+ const requests: Array<Record<string, unknown>> = [];
50
+ await mockTools(page);
51
+ await page.route("**/api/chat", async (route) => {
52
+ requests.push(route.request().postDataJSON() as Record<string, unknown>);
53
+ const firstTurn = requests.length === 1;
54
+ const parts = firstTurn
55
+ ? [
56
+ {
57
+ type: "data-thread",
58
+ data: { threadId: "thread-browser" },
59
+ transient: true,
60
+ },
61
+ { type: "start", messageId: "assistant-1" },
62
+ { type: "start-step" },
63
+ { type: "reasoning-start", id: "thought-1" },
64
+ { type: "reasoning-delta", id: "thought-1", delta: "First thought" },
65
+ { type: "reasoning-end", id: "thought-1" },
66
+ {
67
+ type: "tool-input-available",
68
+ toolCallId: "call-1",
69
+ toolName: "retrieve_tutor_context",
70
+ input: { query: "LoRA" },
71
+ },
72
+ {
73
+ type: "tool-output-available",
74
+ toolCallId: "call-1",
75
+ output: {
76
+ text: largeToolOutput,
77
+ matches: [
78
+ {
79
+ docId: "lora-doc",
80
+ title: "LoRA guide",
81
+ url: "https://example.test/lora",
82
+ sourceKey: "example-docs",
83
+ sourceLabel: "Example docs",
84
+ score: 0.9,
85
+ group: "docs",
86
+ },
87
+ ],
88
+ },
89
+ },
90
+ { type: "reasoning-start", id: "thought-2" },
91
+ { type: "reasoning-delta", id: "thought-2", delta: "Second thought" },
92
+ { type: "reasoning-end", id: "thought-2" },
93
+ {
94
+ type: "source-url",
95
+ sourceId: "lora-doc",
96
+ url: "https://example.test/lora",
97
+ },
98
+ {
99
+ type: "data-source",
100
+ data: {
101
+ docId: "lora-doc",
102
+ title: "LoRA guide",
103
+ url: "https://example.test/lora",
104
+ sourceKey: "example-docs",
105
+ sourceLabel: "Example docs",
106
+ score: 0.9,
107
+ group: "docs",
108
+ },
109
+ },
110
+ { type: "text-start", id: "answer-1" },
111
+ {
112
+ type: "text-delta",
113
+ id: "answer-1",
114
+ delta: "LoRA is parameter-efficient [1](https://example.test/lora).",
115
+ },
116
+ { type: "text-end", id: "answer-1" },
117
+ { type: "finish-step" },
118
+ { type: "finish" },
119
+ ]
120
+ : [
121
+ { type: "start", messageId: "assistant-2" },
122
+ { type: "start-step" },
123
+ { type: "text-start", id: "answer-2" },
124
+ { type: "text-delta", id: "answer-2", delta: "Follow-up answer" },
125
+ { type: "text-end", id: "answer-2" },
126
+ { type: "finish-step" },
127
+ { type: "finish" },
128
+ ];
129
+ await route.fulfill({
130
+ status: 200,
131
+ headers: {
132
+ "content-type": "text/event-stream",
133
+ "x-vercel-ai-ui-message-stream": "v1",
134
+ },
135
+ body: sse(parts),
136
+ });
137
+ });
138
+
139
+ await page.goto("/");
140
+ await expect(page.getByRole("button", { name: "DeepSeek V4 Flash" })).toBeVisible();
141
+ const composer = page.getByRole("textbox");
142
+ await composer.fill("What is LoRA?");
143
+ await page.getByRole("button", { name: "Send message" }).click();
144
+
145
+ await expect(page.getByText("LoRA is parameter-efficient")).toBeVisible();
146
+ const activity = page.getByRole("button", {
147
+ name: "Activity · 1 tool · 2 thoughts · 1 source",
148
+ });
149
+ await expect(activity).toBeVisible();
150
+ await activity.click();
151
+ await expect(page.getByText("First thought")).toBeVisible();
152
+ await expect(page.getByText("Second thought")).toBeVisible();
153
+
154
+ await composer.fill("Can you expand?");
155
+ await page.getByRole("button", { name: "Send message" }).click();
156
+ await expect(page.getByText("Follow-up answer")).toBeVisible();
157
+ await expect.poll(() => requests.length).toBe(2);
158
+
159
+ const followUp = requests[1];
160
+ expect(followUp.threadId).toBe("thread-browser");
161
+ expect(JSON.stringify(followUp)).not.toContain(largeToolOutput.slice(0, 500));
162
+ expect(followUp.messages).toEqual([
163
+ expect.objectContaining({
164
+ role: "user",
165
+ parts: [{ type: "text", text: "What is LoRA?" }],
166
+ }),
167
+ expect.objectContaining({
168
+ role: "assistant",
169
+ parts: [
170
+ {
171
+ type: "text",
172
+ text: "LoRA is parameter-efficient [1](https://example.test/lora).",
173
+ },
174
+ ],
175
+ }),
176
+ expect.objectContaining({
177
+ role: "user",
178
+ parts: [{ type: "text", text: "Can you expand?" }],
179
+ }),
180
+ ]);
181
+ });
182
+
183
+ test("keeps controls and source metadata inside a narrow viewport", async ({ page }) => {
184
+ await page.setViewportSize({ width: 375, height: 812 });
185
+ await mockTools(page);
186
+ await page.goto("/");
187
+
188
+ await expect(page.getByRole("button", { name: "Send message" })).toBeVisible();
189
+ expect(
190
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
191
+ ).toBe(true);
192
+
193
+ await page
194
+ .getByRole("button", { name: "About Example documentation" })
195
+ .click();
196
+ const dialog = page.getByRole("dialog");
197
+ await expect(dialog).toContainText("v1.0 · indexed Jul 2026");
198
+ const box = await dialog.boundingBox();
199
+ expect(box).not.toBeNull();
200
+ expect(box!.x).toBeGreaterThanOrEqual(0);
201
+ expect(box!.x + box!.width).toBeLessThanOrEqual(375);
202
+ });
frontend/lib/api.test.mts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { fetchTools, getApiBaseUrl, type ToolsResponse } from "./api.ts";
4
+
5
+ const ENV_NAME = "NEXT_PUBLIC_AI_TUTOR_API_BASE_URL";
6
+
7
+ function restoreEnvironment(previous: string | undefined) {
8
+ if (previous === undefined) {
9
+ delete process.env[ENV_NAME];
10
+ } else {
11
+ process.env[ENV_NAME] = previous;
12
+ }
13
+ }
14
+
15
+ test("getApiBaseUrl prefers configured URLs and removes trailing slashes", () => {
16
+ const previous = process.env[ENV_NAME];
17
+ try {
18
+ process.env[ENV_NAME] = "https://api.example.test/tutor///";
19
+ assert.equal(getApiBaseUrl(), "https://api.example.test/tutor");
20
+ } finally {
21
+ restoreEnvironment(previous);
22
+ }
23
+ });
24
+
25
+ test("getApiBaseUrl uses the browser origin, then the server fallback", () => {
26
+ const previousEnv = process.env[ENV_NAME];
27
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
28
+ try {
29
+ delete process.env[ENV_NAME];
30
+ Object.defineProperty(globalThis, "window", {
31
+ configurable: true,
32
+ value: { location: { origin: "https://browser.example.test" } },
33
+ });
34
+ assert.equal(getApiBaseUrl(), "https://browser.example.test");
35
+
36
+ delete (globalThis as { window?: unknown }).window;
37
+ assert.equal(getApiBaseUrl(), "http://127.0.0.1:8000");
38
+ } finally {
39
+ restoreEnvironment(previousEnv);
40
+ if (previousWindow) {
41
+ Object.defineProperty(globalThis, "window", previousWindow);
42
+ } else {
43
+ delete (globalThis as { window?: unknown }).window;
44
+ }
45
+ }
46
+ });
47
+
48
+ test("fetchTools sends the model query and AbortSignal and returns parsed JSON", async () => {
49
+ const previousEnv = process.env[ENV_NAME];
50
+ const previousFetch = globalThis.fetch;
51
+ const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
52
+ const expected: ToolsResponse = {
53
+ model: "deepseek:deepseek-v4-flash",
54
+ availableModels: [
55
+ { id: "deepseek:deepseek-v4-flash", label: "DeepSeek V4 Flash" },
56
+ ],
57
+ tools: [],
58
+ };
59
+ const controller = new AbortController();
60
+
61
+ try {
62
+ process.env[ENV_NAME] = "https://api.example.test/base/";
63
+ globalThis.fetch = (async (input, init) => {
64
+ calls.push({ url: String(input), init });
65
+ return new Response(JSON.stringify(expected), {
66
+ status: 200,
67
+ headers: { "content-type": "application/json" },
68
+ });
69
+ }) as typeof fetch;
70
+
71
+ const actual = await fetchTools(
72
+ controller.signal,
73
+ "deepseek:deepseek-v4-flash",
74
+ );
75
+
76
+ assert.deepEqual(actual, expected);
77
+ assert.equal(calls.length, 1);
78
+ assert.equal(
79
+ calls[0]?.url,
80
+ "https://api.example.test/base/api/tools?model=deepseek%3Adeepseek-v4-flash",
81
+ );
82
+ assert.equal(calls[0]?.init?.method, "GET");
83
+ assert.equal(calls[0]?.init?.signal, controller.signal);
84
+ } finally {
85
+ restoreEnvironment(previousEnv);
86
+ globalThis.fetch = previousFetch;
87
+ }
88
+ });
89
+
90
+ test("fetchTools omits an empty model and reports non-success status codes", async () => {
91
+ const previousEnv = process.env[ENV_NAME];
92
+ const previousFetch = globalThis.fetch;
93
+ let requestedUrl = "";
94
+
95
+ try {
96
+ process.env[ENV_NAME] = "https://api.example.test";
97
+ globalThis.fetch = (async (input) => {
98
+ requestedUrl = String(input);
99
+ return new Response("unavailable", { status: 503 });
100
+ }) as typeof fetch;
101
+
102
+ await assert.rejects(fetchTools(undefined, ""), {
103
+ name: "Error",
104
+ message: "Unable to load tools (503)",
105
+ });
106
+ assert.equal(requestedUrl, "https://api.example.test/api/tools");
107
+ } finally {
108
+ restoreEnvironment(previousEnv);
109
+ globalThis.fetch = previousFetch;
110
+ }
111
+ });
frontend/lib/chat-http-transport.test.mts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { Chat } from "@ai-sdk/react";
5
+ import { DefaultChatTransport, type UIMessage, type UIMessageChunk } from "ai";
6
+
7
+ import { prepareTutorChatRequest } from "./chat-transport.ts";
8
+
9
+ function sseResponse(chunks: UIMessageChunk[]) {
10
+ const body = [
11
+ ...chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
12
+ "data: [DONE]\n\n",
13
+ ].join("");
14
+ return new Response(body, {
15
+ status: 200,
16
+ headers: {
17
+ "content-type": "text/event-stream",
18
+ "x-vercel-ai-ui-message-stream": "v1",
19
+ },
20
+ });
21
+ }
22
+
23
+ test("DefaultChatTransport sends compact history and preserves checkpoint identity", async () => {
24
+ const largeToolOutput = "retrieved context ".repeat(20_000);
25
+ const originalAssistantText = " Exact streamed answer \n";
26
+ const initialMessages = [
27
+ {
28
+ id: "user-1",
29
+ role: "user",
30
+ parts: [{ type: "text", text: "Question" }],
31
+ },
32
+ {
33
+ id: "assistant-1",
34
+ role: "assistant",
35
+ parts: [
36
+ { type: "reasoning", text: "Private thought" },
37
+ {
38
+ type: "tool-retrieve_tutor_context",
39
+ toolCallId: "call-1",
40
+ state: "output-available",
41
+ input: { query: "Question" },
42
+ output: { text: largeToolOutput, matches: [] },
43
+ },
44
+ { type: "source-url", sourceId: "source-1", url: "https://example.test" },
45
+ { type: "text", text: originalAssistantText },
46
+ ],
47
+ },
48
+ ] as unknown as UIMessage[];
49
+
50
+ let requestBody: Record<string, unknown> | undefined;
51
+ const receivedData: unknown[] = [];
52
+ const transport = new DefaultChatTransport<UIMessage>({
53
+ api: "https://api.example.test/api/chat",
54
+ prepareSendMessagesRequest: prepareTutorChatRequest,
55
+ fetch: async (_input, init) => {
56
+ requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
57
+ return sseResponse([
58
+ {
59
+ type: "data-thread",
60
+ data: { threadId: "thread-1" },
61
+ transient: true,
62
+ },
63
+ { type: "start", messageId: "assistant-2" },
64
+ { type: "start-step" },
65
+ { type: "text-start", id: "answer-2" },
66
+ { type: "text-delta", id: "answer-2", delta: "Follow-up answer" },
67
+ { type: "text-end", id: "answer-2" },
68
+ { type: "finish-step" },
69
+ { type: "finish" },
70
+ ]);
71
+ },
72
+ });
73
+ const chat = new Chat<UIMessage>({
74
+ id: "chat-1",
75
+ messages: initialMessages,
76
+ transport,
77
+ onData: (part) => receivedData.push(part),
78
+ });
79
+
80
+ await chat.sendMessage(
81
+ { text: "Follow up" },
82
+ {
83
+ body: {
84
+ threadId: "thread-1",
85
+ model: "deepseek:deepseek-v4-flash",
86
+ },
87
+ },
88
+ );
89
+
90
+ assert.ok(requestBody);
91
+ assert.equal(requestBody.threadId, "thread-1");
92
+ assert.equal(requestBody.model, "deepseek:deepseek-v4-flash");
93
+ assert.deepEqual(requestBody.messages, [
94
+ {
95
+ id: "user-1",
96
+ role: "user",
97
+ parts: [{ type: "text", text: "Question" }],
98
+ },
99
+ {
100
+ id: "assistant-1",
101
+ role: "assistant",
102
+ parts: [{ type: "text", text: originalAssistantText }],
103
+ },
104
+ {
105
+ id: chat.messages[2]?.id,
106
+ role: "user",
107
+ parts: [{ type: "text", text: "Follow up" }],
108
+ },
109
+ ]);
110
+ assert.ok(JSON.stringify(requestBody).length < 2_000);
111
+ assert.deepEqual(receivedData, [
112
+ {
113
+ type: "data-thread",
114
+ data: { threadId: "thread-1" },
115
+ transient: true,
116
+ },
117
+ ]);
118
+
119
+ const retainedTool = chat.messages[1]?.parts.find((part) =>
120
+ part.type.startsWith("tool-"),
121
+ );
122
+ assert.equal(
123
+ (retainedTool as { output: { text: string } }).output.text,
124
+ largeToolOutput,
125
+ );
126
+ assert.equal(
127
+ (chat.messages[1]?.parts.at(-1) as { text: string }).text,
128
+ originalAssistantText,
129
+ );
130
+ });
frontend/lib/chat-sdk-lifecycle.test.mts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { Chat } from "@ai-sdk/react";
5
+ import type { ChatTransport, UIMessage, UIMessageChunk } from "ai";
6
+
7
+ type SendOptions = Parameters<ChatTransport<UIMessage>["sendMessages"]>[0];
8
+
9
+ function assistantStream(messageId: string) {
10
+ const chunks: UIMessageChunk[] = [
11
+ { type: "start", messageId },
12
+ { type: "start-step" },
13
+ { type: "text-start", id: `${messageId}-text` },
14
+ { type: "text-delta", id: `${messageId}-text`, delta: "New answer" },
15
+ { type: "text-end", id: `${messageId}-text` },
16
+ { type: "finish-step" },
17
+ { type: "finish" },
18
+ ];
19
+ return new ReadableStream<UIMessageChunk>({
20
+ start(controller) {
21
+ for (const chunk of chunks) controller.enqueue(chunk);
22
+ controller.close();
23
+ },
24
+ });
25
+ }
26
+
27
+ class CapturingTransport implements ChatTransport<UIMessage> {
28
+ calls: Array<Omit<SendOptions, "messages"> & { messages: UIMessage[] }> = [];
29
+
30
+ async sendMessages(options: SendOptions) {
31
+ this.calls.push({
32
+ ...options,
33
+ messages: structuredClone(options.messages),
34
+ });
35
+ return assistantStream(`response-${this.calls.length}`);
36
+ }
37
+
38
+ async reconnectToStream() {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function transcript(): UIMessage[] {
44
+ return [
45
+ { id: "u1", role: "user", parts: [{ type: "text", text: "Question 1" }] },
46
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "Answer 1" }] },
47
+ { id: "u2", role: "user", parts: [{ type: "text", text: "Question 2" }] },
48
+ { id: "a2", role: "assistant", parts: [{ type: "text", text: "Answer 2" }] },
49
+ { id: "u3", role: "user", parts: [{ type: "text", text: "Question 3" }] },
50
+ { id: "a3", role: "assistant", parts: [{ type: "text", text: "Answer 3" }] },
51
+ ];
52
+ }
53
+
54
+ function messageSummary(messages: UIMessage[]) {
55
+ return messages.map((message) => ({
56
+ id: message.id,
57
+ role: message.role,
58
+ text: message.parts
59
+ .filter((part) => part.type === "text")
60
+ .map((part) => part.text)
61
+ .join(""),
62
+ }));
63
+ }
64
+
65
+ test("editing a user turn replaces it and drops its answer and later turns", async () => {
66
+ const transport = new CapturingTransport();
67
+ const chat = new Chat<UIMessage>({
68
+ id: "chat-edit",
69
+ messages: transcript(),
70
+ transport,
71
+ });
72
+
73
+ await chat.sendMessage({ text: "Question 2 edited", messageId: "u2" });
74
+
75
+ assert.equal(transport.calls.length, 1);
76
+ assert.equal(transport.calls[0].trigger, "submit-message");
77
+ assert.equal(transport.calls[0].messageId, "u2");
78
+ assert.deepEqual(messageSummary(transport.calls[0].messages), [
79
+ { id: "u1", role: "user", text: "Question 1" },
80
+ { id: "a1", role: "assistant", text: "Answer 1" },
81
+ { id: "u2", role: "user", text: "Question 2 edited" },
82
+ ]);
83
+ });
84
+
85
+ test("regenerating an assistant turn replays its user prompt and drops later turns", async () => {
86
+ const transport = new CapturingTransport();
87
+ const chat = new Chat<UIMessage>({
88
+ id: "chat-regenerate",
89
+ messages: transcript(),
90
+ transport,
91
+ });
92
+
93
+ await chat.regenerate({ messageId: "a2" });
94
+
95
+ assert.equal(transport.calls.length, 1);
96
+ assert.equal(transport.calls[0].trigger, "regenerate-message");
97
+ assert.equal(transport.calls[0].messageId, "a2");
98
+ assert.deepEqual(messageSummary(transport.calls[0].messages), [
99
+ { id: "u1", role: "user", text: "Question 1" },
100
+ { id: "a1", role: "assistant", text: "Answer 1" },
101
+ { id: "u2", role: "user", text: "Question 2" },
102
+ ]);
103
+ });
104
+
105
+ test("regenerating an earlier answer removes every subsequent turn", async () => {
106
+ const transport = new CapturingTransport();
107
+ const chat = new Chat<UIMessage>({
108
+ id: "chat-regenerate-earlier",
109
+ messages: transcript(),
110
+ transport,
111
+ });
112
+
113
+ await chat.regenerate({ messageId: "a1" });
114
+
115
+ assert.deepEqual(messageSummary(transport.calls[0].messages), [
116
+ { id: "u1", role: "user", text: "Question 1" },
117
+ ]);
118
+ });
frontend/lib/chat-stream.test.mts ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ readUIMessageStream,
6
+ type UIMessage,
7
+ type UIMessageChunk,
8
+ } from "ai";
9
+
10
+ import { compactChatMessages } from "./chat-transport.ts";
11
+
12
+ function streamChunks(chunks: UIMessageChunk[]) {
13
+ return new ReadableStream<UIMessageChunk>({
14
+ start(controller) {
15
+ for (const chunk of chunks) {
16
+ controller.enqueue(chunk);
17
+ }
18
+ controller.close();
19
+ },
20
+ });
21
+ }
22
+
23
+ async function foldChunks(chunks: UIMessageChunk[]) {
24
+ let latest: UIMessage | undefined;
25
+ for await (const message of readUIMessageStream({
26
+ stream: streamChunks(chunks),
27
+ })) {
28
+ latest = message;
29
+ }
30
+ assert.ok(latest, "the stream should produce an assistant message");
31
+ return latest;
32
+ }
33
+
34
+ test("folds reasoning, tools, sources, and text in provider order", async () => {
35
+ const message = await foldChunks([
36
+ { type: "start", messageId: "assistant-1" },
37
+ { type: "start-step" },
38
+ { type: "reasoning-start", id: "thought-1" },
39
+ { type: "reasoning-delta", id: "thought-1", delta: "First thought" },
40
+ { type: "reasoning-end", id: "thought-1" },
41
+ {
42
+ type: "tool-input-start",
43
+ toolCallId: "call-1",
44
+ toolName: "retrieve_tutor_context",
45
+ },
46
+ {
47
+ type: "tool-input-available",
48
+ toolCallId: "call-1",
49
+ toolName: "retrieve_tutor_context",
50
+ input: { query: "LoRA" },
51
+ },
52
+ {
53
+ type: "tool-output-available",
54
+ toolCallId: "call-1",
55
+ output: { text: "retrieved context", matches: [] },
56
+ },
57
+ { type: "reasoning-start", id: "thought-2" },
58
+ { type: "reasoning-delta", id: "thought-2", delta: "Second thought" },
59
+ { type: "reasoning-end", id: "thought-2" },
60
+ {
61
+ type: "source-url",
62
+ sourceId: "source-1",
63
+ url: "https://example.com/lora",
64
+ title: "LoRA guide",
65
+ },
66
+ {
67
+ type: "data-source",
68
+ data: {
69
+ title: "LoRA guide",
70
+ url: "https://example.com/lora",
71
+ source: "docs",
72
+ },
73
+ },
74
+ { type: "text-start", id: "answer-1" },
75
+ {
76
+ type: "text-delta",
77
+ id: "answer-1",
78
+ delta: " LoRA is parameter-efficient.\n",
79
+ },
80
+ { type: "text-end", id: "answer-1" },
81
+ { type: "finish-step" },
82
+ { type: "finish" },
83
+ ]);
84
+
85
+ assert.equal(message.id, "assistant-1");
86
+ assert.equal(message.role, "assistant");
87
+ assert.deepEqual(
88
+ message.parts.map((part) => part.type),
89
+ [
90
+ "step-start",
91
+ "reasoning",
92
+ "tool-retrieve_tutor_context",
93
+ "reasoning",
94
+ "source-url",
95
+ "data-source",
96
+ "text",
97
+ ],
98
+ );
99
+ assert.equal(
100
+ (message.parts[1] as { text: string }).text,
101
+ "First thought",
102
+ );
103
+ assert.equal(
104
+ (message.parts[3] as { text: string }).text,
105
+ "Second thought",
106
+ );
107
+ assert.equal(
108
+ (message.parts[6] as { text: string }).text,
109
+ " LoRA is parameter-efficient.\n",
110
+ );
111
+ });
112
+
113
+ test("folded tool state stays in the UI while follow-up history is text-only", async () => {
114
+ const largeOutput = "retrieved context ".repeat(20_000);
115
+ const assistant = await foldChunks([
116
+ { type: "start", messageId: "assistant-1" },
117
+ { type: "start-step" },
118
+ {
119
+ type: "tool-input-available",
120
+ toolCallId: "call-1",
121
+ toolName: "retrieve_tutor_context",
122
+ input: { query: "LoRA" },
123
+ },
124
+ {
125
+ type: "tool-output-available",
126
+ toolCallId: "call-1",
127
+ output: { text: largeOutput, matches: [] },
128
+ },
129
+ { type: "text-start", id: "answer-1" },
130
+ { type: "text-delta", id: "answer-1", delta: "Exact answer \n" },
131
+ { type: "text-end", id: "answer-1" },
132
+ { type: "finish-step" },
133
+ { type: "finish" },
134
+ ]);
135
+
136
+ const toolPart = assistant.parts.find((part) => part.type.startsWith("tool-"));
137
+ assert.ok(toolPart);
138
+ assert.equal(
139
+ (toolPart as { output: { text: string } }).output.text,
140
+ largeOutput,
141
+ );
142
+
143
+ const compacted = compactChatMessages([
144
+ { id: "user-1", role: "user", parts: [{ type: "text", text: "Question" }] },
145
+ assistant,
146
+ {
147
+ id: "user-2",
148
+ role: "user",
149
+ parts: [{ type: "text", text: "Follow up" }],
150
+ },
151
+ ]);
152
+
153
+ assert.deepEqual(compacted[1].parts, [
154
+ { type: "text", text: "Exact answer \n" },
155
+ ]);
156
+ assert.ok(JSON.stringify(compacted).length < 1_000);
157
+ assert.equal(
158
+ (toolPart as { output: { text: string } }).output.text,
159
+ largeOutput,
160
+ "request compaction must not mutate folded UI state",
161
+ );
162
+ });
frontend/lib/chat-ui.test.mts ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { SourcePartData } from "./api.ts";
4
+ import {
5
+ buildActivityItems,
6
+ buildCitationNumbers,
7
+ buildCitationResolutions,
8
+ citationNumberFor,
9
+ getMessageCitations,
10
+ getMessageTextContent,
11
+ getOrderedMessageBlocks,
12
+ hasRenderableContent,
13
+ resolveCitationHref,
14
+ type TutorMessage,
15
+ type TutorMessagePart,
16
+ } from "./chat-ui.ts";
17
+
18
+ function assistantMessage(
19
+ parts: TutorMessagePart[],
20
+ id = "assistant-1",
21
+ ): TutorMessage {
22
+ return { id, role: "assistant", parts } as unknown as TutorMessage;
23
+ }
24
+
25
+ function source(overrides: Partial<SourcePartData> = {}): SourcePartData {
26
+ return {
27
+ docId: "doc-1",
28
+ title: "A useful source",
29
+ url: "https://docs.example.test/guide",
30
+ sourceKey: "docs",
31
+ sourceLabel: "Example docs",
32
+ score: 0.9,
33
+ group: "docs",
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ test("citation numbers follow rendered-link order and ignore non-rendered links", () => {
39
+ const message = assistantMessage([
40
+ {
41
+ type: "text",
42
+ text: [
43
+ "[Nested [label]](https://docs.example.test/Spring_(framework)#intro)",
44
+ "`[inline code](https://ignored.example.test/inline)`",
45
+ "![image](https://ignored.example.test/image)",
46
+ "```md\n[fenced](https://ignored.example.test/fenced)\n```",
47
+ "[Duplicate](https://docs.example.test/Spring_(framework)/)",
48
+ ].join("\n"),
49
+ },
50
+ {
51
+ type: "text",
52
+ text: [
53
+ "[Encoded](https://docs.example.test/caf%C3%A9)",
54
+ "[Unicode duplicate](https://docs.example.test/café#heading)",
55
+ "[Angle form](<https://other.example.test/a(b)>)",
56
+ "[With title](https://third.example.test/path \"Source title\")",
57
+ "[Malformed](https://ignored.example.test/wrong]",
58
+ "[Relative](raw/docs/example.md)",
59
+ ].join("\n"),
60
+ },
61
+ ]);
62
+
63
+ const numbers = buildCitationNumbers(message);
64
+
65
+ assert.deepEqual([...numbers.entries()], [
66
+ ["https://docs.example.test/Spring_(framework)", 1],
67
+ ["https://docs.example.test/caf%C3%A9", 2],
68
+ ["https://other.example.test/a(b)", 3],
69
+ ["https://third.example.test/path", 4],
70
+ ]);
71
+ assert.equal(citationNumberFor(numbers, "https://docs.example.test/café/"), 2);
72
+ assert.equal(citationNumberFor(numbers, "https://ignored.example.test/inline"), undefined);
73
+ });
74
+
75
+ test("KB references resolve to one navigable citation number", () => {
76
+ const canonicalUrl = "https://academy.example.test/lessons/lora";
77
+ const kbSource = source({
78
+ docId: "lora-doc",
79
+ path: "raw/docs/peft/lora.md",
80
+ url: canonicalUrl,
81
+ });
82
+ const message = assistantMessage([
83
+ {
84
+ type: "text",
85
+ text: [
86
+ "[By id](kb://doc/lora-doc)",
87
+ "[By relative path](./raw/docs/peft/lora.md)",
88
+ "[By full path](data/kb/raw/docs/peft/lora.md)",
89
+ "[Direct URL](https://academy.example.test/lessons/lora#examples)",
90
+ "[Missing KB file](raw/docs/peft/missing.md)",
91
+ ].join(" "),
92
+ },
93
+ { type: "data-source", data: kbSource },
94
+ ]);
95
+
96
+ const resolutions = buildCitationResolutions(message);
97
+ assert.deepEqual([...resolutions.entries()], [
98
+ ["kb://doc/lora-doc", canonicalUrl],
99
+ ["raw/docs/peft/lora.md", canonicalUrl],
100
+ ]);
101
+ assert.equal(
102
+ resolveCitationHref("data/kb/raw/docs/peft/lora.md", resolutions),
103
+ canonicalUrl,
104
+ );
105
+ assert.equal(resolveCitationHref("raw/docs/peft/missing.md", resolutions), undefined);
106
+
107
+ const numbers = buildCitationNumbers(message, resolutions);
108
+ assert.deepEqual([...numbers.entries()], [[canonicalUrl, 1]]);
109
+ assert.equal(citationNumberFor(numbers, `${canonicalUrl}/#details`), 1);
110
+ });
111
+
112
+ test("server-provided sources preserve order and deduplicate normalized URLs", () => {
113
+ const longTitle = "T".repeat(90);
114
+ const message = assistantMessage([
115
+ {
116
+ type: "data-source",
117
+ data: source({
118
+ title: longTitle,
119
+ url: "https://docs.example.test/guide/#intro",
120
+ group: "docs",
121
+ }),
122
+ },
123
+ {
124
+ type: "data-source",
125
+ data: source({
126
+ title: "Duplicate",
127
+ url: "https://docs.example.test/guide/",
128
+ group: "docs",
129
+ }),
130
+ },
131
+ {
132
+ type: "data-source",
133
+ data: source({
134
+ title: " — ",
135
+ url: "https://www.search.example.test/result",
136
+ sourceLabel: "Search result",
137
+ group: "web",
138
+ }),
139
+ },
140
+ {
141
+ type: "data-source",
142
+ data: source({
143
+ title: "Course lesson",
144
+ url: "https://academy.example.test/course/lesson",
145
+ sourceLabel: "Agent Engineering",
146
+ group: "courses",
147
+ }),
148
+ },
149
+ { type: "data-source", data: source({ url: " " }) },
150
+ ]);
151
+
152
+ assert.deepEqual(getMessageCitations(message), [
153
+ {
154
+ label: longTitle.slice(0, 80),
155
+ url: "https://docs.example.test/guide/#intro",
156
+ kind: "doc",
157
+ sublabel: "Example docs",
158
+ },
159
+ {
160
+ label: "search.example.test",
161
+ url: "https://www.search.example.test/result",
162
+ kind: "web",
163
+ sublabel: "search.example.test",
164
+ },
165
+ {
166
+ label: "Course lesson",
167
+ url: "https://academy.example.test/course/lesson",
168
+ kind: "course",
169
+ sublabel: "Agent Engineering",
170
+ },
171
+ ]);
172
+ });
173
+
174
+ test("ordered blocks retain provider order and ignored parts do not split blocks", () => {
175
+ const reasoningOne = { type: "reasoning", text: "First thought" };
176
+ const reasoningTwo = { type: "reasoning", text: "Second fragment" };
177
+ const tool = {
178
+ type: "tool-retrieve_tutor_context",
179
+ toolCallId: "call-1",
180
+ state: "output-available",
181
+ };
182
+ const reasoningThree = { type: "reasoning", text: "After the tool" };
183
+ const textOne = { type: "text", text: "Answer one" };
184
+ const textTwo = { type: "text", text: "Answer two" };
185
+ const finalTool = {
186
+ type: "tool-web_search",
187
+ toolCallId: "call-2",
188
+ state: "input-available",
189
+ };
190
+ const message = assistantMessage([
191
+ { type: "data-source", data: source() },
192
+ reasoningOne,
193
+ { type: "data-context_stats", data: { tokens: 10 } },
194
+ reasoningTwo,
195
+ tool,
196
+ reasoningThree,
197
+ textOne,
198
+ { type: "data-source", data: source() },
199
+ textTwo,
200
+ finalTool,
201
+ ]);
202
+
203
+ assert.deepEqual(getOrderedMessageBlocks(message), [
204
+ {
205
+ key: "assistant-1-activity-0",
206
+ kind: "activity",
207
+ parts: [reasoningOne, reasoningTwo, tool, reasoningThree],
208
+ },
209
+ {
210
+ key: "assistant-1-text-1",
211
+ kind: "text",
212
+ parts: [textOne, textTwo],
213
+ },
214
+ {
215
+ key: "assistant-1-activity-2",
216
+ kind: "activity",
217
+ parts: [finalTool],
218
+ },
219
+ ]);
220
+ });
221
+
222
+ test("activity items merge reasoning fragments but split them at tool calls", () => {
223
+ const tool = {
224
+ type: "tool-run_kb_command",
225
+ toolCallId: "call-1",
226
+ state: "output-available",
227
+ };
228
+ const parts: TutorMessagePart[] = [
229
+ { type: "reasoning", text: " First fragment " },
230
+ { type: "data-source", data: source() },
231
+ { type: "reasoning", text: "" },
232
+ { type: "reasoning", text: "Second fragment" },
233
+ tool,
234
+ { type: "data-context_stats", data: {} },
235
+ { type: "reasoning", text: "Final thought" },
236
+ ];
237
+
238
+ assert.deepEqual(buildActivityItems(parts), [
239
+ {
240
+ kind: "reasoning",
241
+ key: "r-0",
242
+ text: "First fragment\n\nSecond fragment",
243
+ },
244
+ { kind: "tool", key: "t-1-call-1", part: tool },
245
+ { kind: "reasoning", key: "r-2", text: "Final thought" },
246
+ ]);
247
+ });
248
+
249
+ test("renderability ignores empty stream placeholders but retains real activity", () => {
250
+ assert.equal(hasRenderableContent(assistantMessage([])), false);
251
+ assert.equal(
252
+ hasRenderableContent(assistantMessage([{ type: "data-source", data: source() }])),
253
+ false,
254
+ );
255
+ assert.equal(hasRenderableContent(assistantMessage([{ type: "text", text: "" }])), false);
256
+ assert.equal(
257
+ hasRenderableContent(assistantMessage([{ type: "reasoning", text: "" }])),
258
+ false,
259
+ );
260
+ assert.equal(
261
+ hasRenderableContent(assistantMessage([{ type: "reasoning", text: "Thinking" }])),
262
+ true,
263
+ );
264
+ assert.equal(
265
+ hasRenderableContent(
266
+ assistantMessage([{ type: "tool-web_search", state: "input-streaming" }]),
267
+ ),
268
+ true,
269
+ );
270
+ });
271
+
272
+ test("message text joins trimmed non-empty text parts without reshaping content inside them", () => {
273
+ const message = assistantMessage([
274
+ { type: "text", text: " First paragraph\n " },
275
+ { type: "reasoning", text: "Not user-facing" },
276
+ { type: "text", text: " " },
277
+ { type: "text", text: "Second paragraph\nwith another line" },
278
+ ]);
279
+
280
+ assert.equal(
281
+ getMessageTextContent(message),
282
+ "First paragraph\n\nSecond paragraph\nwith another line",
283
+ );
284
+ });
frontend/lib/chat-ui.ts CHANGED
@@ -109,7 +109,14 @@ export function hasRenderableContent(message: TutorMessage): boolean {
109
  continue;
110
  }
111
  const type = String(part.type);
112
- if (type === "text" || type === "reasoning" || type.startsWith("tool-")) {
 
 
 
 
 
 
 
113
  return true;
114
  }
115
  }
 
109
  continue;
110
  }
111
  const type = String(part.type);
112
+ if (type === "text" || type === "reasoning") {
113
+ const text = "text" in part ? part.text : "";
114
+ if (typeof text === "string" && text.trim()) {
115
+ return true;
116
+ }
117
+ continue;
118
+ }
119
+ if (type.startsWith("tool-")) {
120
  return true;
121
  }
122
  }
frontend/package-lock.json CHANGED
@@ -22,16 +22,29 @@
22
  },
23
  "devDependencies": {
24
  "@eslint/eslintrc": "^3.3.1",
 
25
  "@tailwindcss/postcss": "^4.1.17",
 
 
 
26
  "@types/node": "^24.10.1",
27
  "@types/react": "^19.2.2",
28
  "@types/react-dom": "^19.2.2",
29
  "eslint": "^9.39.4",
30
  "eslint-config-next": "^16.2.3",
 
31
  "tailwindcss": "^4.2.2",
32
- "typescript": "^6.0.3"
 
33
  }
34
  },
 
 
 
 
 
 
 
35
  "node_modules/@ai-sdk/gateway": {
36
  "version": "3.0.143",
37
  "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.143.tgz",
@@ -109,6 +122,57 @@
109
  "url": "https://github.com/sponsors/sindresorhus"
110
  }
111
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  "node_modules/@babel/code-frame": {
113
  "version": "7.29.7",
114
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -301,6 +365,16 @@
301
  "node": ">=6.0.0"
302
  }
303
  },
 
 
 
 
 
 
 
 
 
 
304
  "node_modules/@babel/template": {
305
  "version": "7.29.7",
306
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -349,6 +423,159 @@
349
  "node": ">=6.9.0"
350
  }
351
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  "node_modules/@emnapi/core": {
353
  "version": "1.10.0",
354
  "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -526,6 +753,24 @@
526
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
527
  }
528
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  "node_modules/@humanfs/core": {
530
  "version": "0.19.2",
531
  "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1388,72 +1633,36 @@
1388
  "node": ">=8.0.0"
1389
  }
1390
  },
1391
- "node_modules/@rtsao/scc": {
1392
- "version": "1.1.0",
1393
- "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
1394
- "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
1395
- "dev": true,
1396
- "license": "MIT"
1397
- },
1398
- "node_modules/@standard-schema/spec": {
1399
- "version": "1.1.0",
1400
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1401
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1402
- "license": "MIT"
1403
- },
1404
- "node_modules/@swc/helpers": {
1405
- "version": "0.5.15",
1406
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
1407
- "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
1408
- "license": "Apache-2.0",
1409
- "dependencies": {
1410
- "tslib": "^2.8.0"
1411
- }
1412
- },
1413
- "node_modules/@tailwindcss/node": {
1414
- "version": "4.3.2",
1415
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
1416
- "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
1417
  "dev": true,
1418
  "license": "MIT",
1419
- "dependencies": {
1420
- "@jridgewell/remapping": "^2.3.5",
1421
- "enhanced-resolve": "5.21.6",
1422
- "jiti": "^2.7.0",
1423
- "lightningcss": "1.32.0",
1424
- "magic-string": "^0.30.21",
1425
- "source-map-js": "^1.2.1",
1426
- "tailwindcss": "4.3.2"
1427
  }
1428
  },
1429
- "node_modules/@tailwindcss/oxide": {
1430
- "version": "4.3.2",
1431
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
1432
- "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
1433
- "dev": true,
1434
- "license": "MIT",
1435
- "engines": {
1436
- "node": ">= 20"
1437
  },
1438
- "optionalDependencies": {
1439
- "@tailwindcss/oxide-android-arm64": "4.3.2",
1440
- "@tailwindcss/oxide-darwin-arm64": "4.3.2",
1441
- "@tailwindcss/oxide-darwin-x64": "4.3.2",
1442
- "@tailwindcss/oxide-freebsd-x64": "4.3.2",
1443
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
1444
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
1445
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
1446
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
1447
- "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
1448
- "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
1449
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
1450
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
1451
  }
1452
  },
1453
- "node_modules/@tailwindcss/oxide-android-arm64": {
1454
- "version": "4.3.2",
1455
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
1456
- "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
1457
  "cpu": [
1458
  "arm64"
1459
  ],
@@ -1464,13 +1673,13 @@
1464
  "android"
1465
  ],
1466
  "engines": {
1467
- "node": ">= 20"
1468
  }
1469
  },
1470
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
1471
- "version": "4.3.2",
1472
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
1473
- "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
1474
  "cpu": [
1475
  "arm64"
1476
  ],
@@ -1481,13 +1690,13 @@
1481
  "darwin"
1482
  ],
1483
  "engines": {
1484
- "node": ">= 20"
1485
  }
1486
  },
1487
- "node_modules/@tailwindcss/oxide-darwin-x64": {
1488
- "version": "4.3.2",
1489
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
1490
- "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
1491
  "cpu": [
1492
  "x64"
1493
  ],
@@ -1498,13 +1707,13 @@
1498
  "darwin"
1499
  ],
1500
  "engines": {
1501
- "node": ">= 20"
1502
  }
1503
  },
1504
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
1505
- "version": "4.3.2",
1506
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
1507
- "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
1508
  "cpu": [
1509
  "x64"
1510
  ],
@@ -1515,13 +1724,13 @@
1515
  "freebsd"
1516
  ],
1517
  "engines": {
1518
- "node": ">= 20"
1519
  }
1520
  },
1521
- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
1522
- "version": "4.3.2",
1523
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
1524
- "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
1525
  "cpu": [
1526
  "arm"
1527
  ],
@@ -1532,13 +1741,13 @@
1532
  "linux"
1533
  ],
1534
  "engines": {
1535
- "node": ">= 20"
1536
  }
1537
  },
1538
- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
1539
- "version": "4.3.2",
1540
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
1541
- "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
1542
  "cpu": [
1543
  "arm64"
1544
  ],
@@ -1552,13 +1761,13 @@
1552
  "linux"
1553
  ],
1554
  "engines": {
1555
- "node": ">= 20"
1556
  }
1557
  },
1558
- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
1559
- "version": "4.3.2",
1560
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
1561
- "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
1562
  "cpu": [
1563
  "arm64"
1564
  ],
@@ -1572,15 +1781,393 @@
1572
  "linux"
1573
  ],
1574
  "engines": {
1575
- "node": ">= 20"
1576
  }
1577
  },
1578
- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
1579
- "version": "4.3.2",
1580
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
1581
- "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
1582
  "cpu": [
1583
- "x64"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1584
  ],
1585
  "dev": true,
1586
  "libc": [
@@ -1693,6 +2280,107 @@
1693
  "tailwindcss": "4.3.2"
1694
  }
1695
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1696
  "node_modules/@tybys/wasm-util": {
1697
  "version": "0.10.3",
1698
  "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -1704,6 +2392,25 @@
1704
  "tslib": "^2.4.0"
1705
  }
1706
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1707
  "node_modules/@types/debug": {
1708
  "version": "4.1.13",
1709
  "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -1713,6 +2420,13 @@
1713
  "@types/ms": "*"
1714
  }
1715
  },
 
 
 
 
 
 
 
1716
  "node_modules/@types/estree": {
1717
  "version": "1.0.9",
1718
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2428,41 +3142,154 @@
2428
  "win32"
2429
  ]
2430
  },
2431
- "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
2432
- "version": "1.12.2",
2433
- "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
2434
- "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
2435
- "cpu": [
2436
- "ia32"
2437
- ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2438
  "dev": true,
2439
  "license": "MIT",
2440
- "optional": true,
2441
- "os": [
2442
- "win32"
2443
- ]
 
 
 
 
 
2444
  },
2445
- "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
2446
- "version": "1.12.2",
2447
- "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
2448
- "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
2449
- "cpu": [
2450
- "x64"
2451
- ],
2452
  "dev": true,
2453
  "license": "MIT",
2454
- "optional": true,
2455
- "os": [
2456
- "win32"
2457
- ]
2458
  },
2459
- "node_modules/@vercel/oidc": {
2460
- "version": "3.2.0",
2461
- "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
2462
- "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
2463
- "license": "Apache-2.0",
2464
- "engines": {
2465
- "node": ">= 20"
 
 
 
 
 
 
2466
  }
2467
  },
2468
  "node_modules/acorn": {
@@ -2523,6 +3350,17 @@
2523
  "url": "https://github.com/sponsors/epoberezkin"
2524
  }
2525
  },
 
 
 
 
 
 
 
 
 
 
 
2526
  "node_modules/ansi-styles": {
2527
  "version": "4.3.0",
2528
  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2716,6 +3554,16 @@
2716
  "url": "https://github.com/sponsors/ljharb"
2717
  }
2718
  },
 
 
 
 
 
 
 
 
 
 
2719
  "node_modules/ast-types-flow": {
2720
  "version": "0.0.8",
2721
  "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -2798,6 +3646,16 @@
2798
  "node": ">=6.0.0"
2799
  }
2800
  },
 
 
 
 
 
 
 
 
 
 
2801
  "node_modules/brace-expansion": {
2802
  "version": "1.1.15",
2803
  "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2946,6 +3804,16 @@
2946
  "url": "https://github.com/sponsors/wooorm"
2947
  }
2948
  },
 
 
 
 
 
 
 
 
 
 
2949
  "node_modules/chalk": {
2950
  "version": "4.1.2",
2951
  "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3077,6 +3945,27 @@
3077
  "node": ">= 8"
3078
  }
3079
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3080
  "node_modules/csstype": {
3081
  "version": "3.2.3",
3082
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3090,6 +3979,20 @@
3090
  "dev": true,
3091
  "license": "BSD-2-Clause"
3092
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3093
  "node_modules/data-view-buffer": {
3094
  "version": "1.0.2",
3095
  "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3161,6 +4064,13 @@
3161
  }
3162
  }
3163
  },
 
 
 
 
 
 
 
3164
  "node_modules/decode-named-character-reference": {
3165
  "version": "1.3.0",
3166
  "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -3262,6 +4172,14 @@
3262
  "node": ">=0.10.0"
3263
  }
3264
  },
 
 
 
 
 
 
 
 
3265
  "node_modules/dunder-proto": {
3266
  "version": "1.0.1",
3267
  "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3305,6 +4223,19 @@
3305
  "node": ">=10.13.0"
3306
  }
3307
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
3308
  "node_modules/es-abstract": {
3309
  "version": "1.24.2",
3310
  "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -3441,6 +4372,13 @@
3441
  "node": ">= 0.4"
3442
  }
3443
  },
 
 
 
 
 
 
 
3444
  "node_modules/es-object-atoms": {
3445
  "version": "1.1.2",
3446
  "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -3933,6 +4871,16 @@
3933
  "url": "https://opencollective.com/unified"
3934
  }
3935
  },
 
 
 
 
 
 
 
 
 
 
3936
  "node_modules/esutils": {
3937
  "version": "2.0.3",
3938
  "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3952,6 +4900,16 @@
3952
  "node": ">=18.0.0"
3953
  }
3954
  },
 
 
 
 
 
 
 
 
 
 
3955
  "node_modules/extend": {
3956
  "version": "3.0.2",
3957
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -4099,6 +5057,21 @@
4099
  "url": "https://github.com/sponsors/ljharb"
4100
  }
4101
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4102
  "node_modules/function-bind": {
4103
  "version": "1.1.2",
4104
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4485,6 +5458,19 @@
4485
  "node": ">=12.0.0"
4486
  }
4487
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
4488
  "node_modules/html-url-attributes": {
4489
  "version": "3.0.1",
4490
  "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -4532,6 +5518,16 @@
4532
  "node": ">=0.8.19"
4533
  }
4534
  },
 
 
 
 
 
 
 
 
 
 
4535
  "node_modules/inline-style-parser": {
4536
  "version": "0.2.7",
4537
  "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -4895,6 +5891,13 @@
4895
  "url": "https://github.com/sponsors/sindresorhus"
4896
  }
4897
  },
 
 
 
 
 
 
 
4898
  "node_modules/is-regex": {
4899
  "version": "1.2.1",
4900
  "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5112,6 +6115,57 @@
5112
  "js-yaml": "bin/js-yaml.js"
5113
  }
5114
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5115
  "node_modules/jsesc": {
5116
  "version": "3.1.0",
5117
  "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5578,6 +6632,17 @@
5578
  "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5579
  }
5580
  },
 
 
 
 
 
 
 
 
 
 
 
5581
  "node_modules/magic-string": {
5582
  "version": "0.30.21",
5583
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5890,6 +6955,13 @@
5890
  "url": "https://opencollective.com/unified"
5891
  }
5892
  },
 
 
 
 
 
 
 
5893
  "node_modules/merge2": {
5894
  "version": "1.4.1",
5895
  "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -6477,6 +7549,16 @@
6477
  "node": ">=8.6"
6478
  }
6479
  },
 
 
 
 
 
 
 
 
 
 
6480
  "node_modules/minimatch": {
6481
  "version": "3.1.5",
6482
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -6780,6 +7862,20 @@
6780
  "url": "https://github.com/sponsors/ljharb"
6781
  }
6782
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6783
  "node_modules/optionator": {
6784
  "version": "0.9.4",
6785
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6886,6 +7982,19 @@
6886
  "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
6887
  "license": "MIT"
6888
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
6889
  "node_modules/path-exists": {
6890
  "version": "4.0.0",
6891
  "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6913,6 +8022,13 @@
6913
  "dev": true,
6914
  "license": "MIT"
6915
  },
 
 
 
 
 
 
 
6916
  "node_modules/picocolors": {
6917
  "version": "1.1.1",
6918
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6932,6 +8048,52 @@
6932
  "url": "https://github.com/sponsors/jonschlinkert"
6933
  }
6934
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6935
  "node_modules/possible-typed-array-names": {
6936
  "version": "1.1.0",
6937
  "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6943,9 +8105,9 @@
6943
  }
6944
  },
6945
  "node_modules/postcss": {
6946
- "version": "8.5.16",
6947
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
6948
- "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
6949
  "dev": true,
6950
  "funding": [
6951
  {
@@ -6981,6 +8143,44 @@
6981
  "node": ">= 0.8.0"
6982
  }
6983
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6984
  "node_modules/prop-types": {
6985
  "version": "15.8.1",
6986
  "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7089,6 +8289,20 @@
7089
  "react": ">=18"
7090
  }
7091
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7092
  "node_modules/reflect.getprototypeof": {
7093
  "version": "1.0.10",
7094
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7216,6 +8430,16 @@
7216
  "url": "https://opencollective.com/unified"
7217
  }
7218
  },
 
 
 
 
 
 
 
 
 
 
7219
  "node_modules/resolve": {
7220
  "version": "2.0.0-next.7",
7221
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -7271,6 +8495,40 @@
7271
  "node": ">=0.10.0"
7272
  }
7273
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7274
  "node_modules/run-parallel": {
7275
  "version": "1.2.0",
7276
  "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7350,6 +8608,19 @@
7350
  "url": "https://github.com/sponsors/ljharb"
7351
  }
7352
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
7353
  "node_modules/scheduler": {
7354
  "version": "0.27.0",
7355
  "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -7572,6 +8843,13 @@
7572
  "url": "https://github.com/sponsors/ljharb"
7573
  }
7574
  },
 
 
 
 
 
 
 
7575
  "node_modules/source-map-js": {
7576
  "version": "1.2.1",
7577
  "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7598,6 +8876,20 @@
7598
  "dev": true,
7599
  "license": "MIT"
7600
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7601
  "node_modules/stop-iteration-iterator": {
7602
  "version": "1.1.0",
7603
  "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -7750,6 +9042,19 @@
7750
  "node": ">=4"
7751
  }
7752
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
7753
  "node_modules/strip-json-comments": {
7754
  "version": "3.1.1",
7755
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -7843,6 +9148,13 @@
7843
  "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
7844
  }
7845
  },
 
 
 
 
 
 
 
7846
  "node_modules/tailwindcss": {
7847
  "version": "4.3.2",
7848
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
@@ -7876,6 +9188,23 @@
7876
  "url": "https://github.com/sponsors/sindresorhus"
7877
  }
7878
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7879
  "node_modules/tinyglobby": {
7880
  "version": "0.2.17",
7881
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -7924,6 +9253,36 @@
7924
  "url": "https://github.com/sponsors/jonschlinkert"
7925
  }
7926
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7927
  "node_modules/to-regex-range": {
7928
  "version": "5.0.1",
7929
  "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7937,6 +9296,32 @@
7937
  "node": ">=8.0"
7938
  }
7939
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7940
  "node_modules/trim-lines": {
7941
  "version": "3.0.1",
7942
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -8150,6 +9535,16 @@
8150
  "url": "https://github.com/sponsors/ljharb"
8151
  }
8152
  },
 
 
 
 
 
 
 
 
 
 
8153
  "node_modules/undici-types": {
8154
  "version": "7.18.2",
8155
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -8374,6 +9769,248 @@
8374
  "url": "https://opencollective.com/unified"
8375
  }
8376
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8377
  "node_modules/which": {
8378
  "version": "2.0.2",
8379
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8479,6 +10116,23 @@
8479
  "url": "https://github.com/sponsors/ljharb"
8480
  }
8481
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8482
  "node_modules/word-wrap": {
8483
  "version": "1.2.5",
8484
  "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8489,6 +10143,23 @@
8489
  "node": ">=0.10.0"
8490
  }
8491
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8492
  "node_modules/yallist": {
8493
  "version": "3.1.1",
8494
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
 
22
  },
23
  "devDependencies": {
24
  "@eslint/eslintrc": "^3.3.1",
25
+ "@playwright/test": "^1.61.1",
26
  "@tailwindcss/postcss": "^4.1.17",
27
+ "@testing-library/jest-dom": "^6.9.1",
28
+ "@testing-library/react": "^16.3.2",
29
+ "@testing-library/user-event": "^14.6.1",
30
  "@types/node": "^24.10.1",
31
  "@types/react": "^19.2.2",
32
  "@types/react-dom": "^19.2.2",
33
  "eslint": "^9.39.4",
34
  "eslint-config-next": "^16.2.3",
35
+ "jsdom": "^29.1.1",
36
  "tailwindcss": "^4.2.2",
37
+ "typescript": "^6.0.3",
38
+ "vitest": "^4.1.10"
39
  }
40
  },
41
+ "node_modules/@adobe/css-tools": {
42
+ "version": "4.5.0",
43
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
44
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
45
+ "dev": true,
46
+ "license": "MIT"
47
+ },
48
  "node_modules/@ai-sdk/gateway": {
49
  "version": "3.0.143",
50
  "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.143.tgz",
 
122
  "url": "https://github.com/sponsors/sindresorhus"
123
  }
124
  },
125
+ "node_modules/@asamuzakjp/css-color": {
126
+ "version": "5.1.11",
127
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
128
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
129
+ "dev": true,
130
+ "license": "MIT",
131
+ "dependencies": {
132
+ "@asamuzakjp/generational-cache": "^1.0.1",
133
+ "@csstools/css-calc": "^3.2.0",
134
+ "@csstools/css-color-parser": "^4.1.0",
135
+ "@csstools/css-parser-algorithms": "^4.0.0",
136
+ "@csstools/css-tokenizer": "^4.0.0"
137
+ },
138
+ "engines": {
139
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
140
+ }
141
+ },
142
+ "node_modules/@asamuzakjp/dom-selector": {
143
+ "version": "7.1.1",
144
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
145
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
146
+ "dev": true,
147
+ "license": "MIT",
148
+ "dependencies": {
149
+ "@asamuzakjp/generational-cache": "^1.0.1",
150
+ "@asamuzakjp/nwsapi": "^2.3.9",
151
+ "bidi-js": "^1.0.3",
152
+ "css-tree": "^3.2.1",
153
+ "is-potential-custom-element-name": "^1.0.1"
154
+ },
155
+ "engines": {
156
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
157
+ }
158
+ },
159
+ "node_modules/@asamuzakjp/generational-cache": {
160
+ "version": "1.0.1",
161
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
162
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
163
+ "dev": true,
164
+ "license": "MIT",
165
+ "engines": {
166
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
167
+ }
168
+ },
169
+ "node_modules/@asamuzakjp/nwsapi": {
170
+ "version": "2.3.9",
171
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
172
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
173
+ "dev": true,
174
+ "license": "MIT"
175
+ },
176
  "node_modules/@babel/code-frame": {
177
  "version": "7.29.7",
178
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
 
365
  "node": ">=6.0.0"
366
  }
367
  },
368
+ "node_modules/@babel/runtime": {
369
+ "version": "7.29.7",
370
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
371
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
372
+ "dev": true,
373
+ "license": "MIT",
374
+ "engines": {
375
+ "node": ">=6.9.0"
376
+ }
377
+ },
378
  "node_modules/@babel/template": {
379
  "version": "7.29.7",
380
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
 
423
  "node": ">=6.9.0"
424
  }
425
  },
426
+ "node_modules/@bramus/specificity": {
427
+ "version": "2.4.2",
428
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
429
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
430
+ "dev": true,
431
+ "license": "MIT",
432
+ "dependencies": {
433
+ "css-tree": "^3.0.0"
434
+ },
435
+ "bin": {
436
+ "specificity": "bin/cli.js"
437
+ }
438
+ },
439
+ "node_modules/@csstools/color-helpers": {
440
+ "version": "6.1.0",
441
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
442
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
443
+ "dev": true,
444
+ "funding": [
445
+ {
446
+ "type": "github",
447
+ "url": "https://github.com/sponsors/csstools"
448
+ },
449
+ {
450
+ "type": "opencollective",
451
+ "url": "https://opencollective.com/csstools"
452
+ }
453
+ ],
454
+ "license": "MIT-0",
455
+ "engines": {
456
+ "node": ">=20.19.0"
457
+ }
458
+ },
459
+ "node_modules/@csstools/css-calc": {
460
+ "version": "3.2.1",
461
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
462
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
463
+ "dev": true,
464
+ "funding": [
465
+ {
466
+ "type": "github",
467
+ "url": "https://github.com/sponsors/csstools"
468
+ },
469
+ {
470
+ "type": "opencollective",
471
+ "url": "https://opencollective.com/csstools"
472
+ }
473
+ ],
474
+ "license": "MIT",
475
+ "engines": {
476
+ "node": ">=20.19.0"
477
+ },
478
+ "peerDependencies": {
479
+ "@csstools/css-parser-algorithms": "^4.0.0",
480
+ "@csstools/css-tokenizer": "^4.0.0"
481
+ }
482
+ },
483
+ "node_modules/@csstools/css-color-parser": {
484
+ "version": "4.1.9",
485
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
486
+ "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
487
+ "dev": true,
488
+ "funding": [
489
+ {
490
+ "type": "github",
491
+ "url": "https://github.com/sponsors/csstools"
492
+ },
493
+ {
494
+ "type": "opencollective",
495
+ "url": "https://opencollective.com/csstools"
496
+ }
497
+ ],
498
+ "license": "MIT",
499
+ "dependencies": {
500
+ "@csstools/color-helpers": "^6.1.0",
501
+ "@csstools/css-calc": "^3.2.1"
502
+ },
503
+ "engines": {
504
+ "node": ">=20.19.0"
505
+ },
506
+ "peerDependencies": {
507
+ "@csstools/css-parser-algorithms": "^4.0.0",
508
+ "@csstools/css-tokenizer": "^4.0.0"
509
+ }
510
+ },
511
+ "node_modules/@csstools/css-parser-algorithms": {
512
+ "version": "4.0.0",
513
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
514
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
515
+ "dev": true,
516
+ "funding": [
517
+ {
518
+ "type": "github",
519
+ "url": "https://github.com/sponsors/csstools"
520
+ },
521
+ {
522
+ "type": "opencollective",
523
+ "url": "https://opencollective.com/csstools"
524
+ }
525
+ ],
526
+ "license": "MIT",
527
+ "engines": {
528
+ "node": ">=20.19.0"
529
+ },
530
+ "peerDependencies": {
531
+ "@csstools/css-tokenizer": "^4.0.0"
532
+ }
533
+ },
534
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
535
+ "version": "1.1.6",
536
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
537
+ "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
538
+ "dev": true,
539
+ "funding": [
540
+ {
541
+ "type": "github",
542
+ "url": "https://github.com/sponsors/csstools"
543
+ },
544
+ {
545
+ "type": "opencollective",
546
+ "url": "https://opencollective.com/csstools"
547
+ }
548
+ ],
549
+ "license": "MIT-0",
550
+ "peerDependencies": {
551
+ "css-tree": "^3.2.1"
552
+ },
553
+ "peerDependenciesMeta": {
554
+ "css-tree": {
555
+ "optional": true
556
+ }
557
+ }
558
+ },
559
+ "node_modules/@csstools/css-tokenizer": {
560
+ "version": "4.0.0",
561
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
562
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
563
+ "dev": true,
564
+ "funding": [
565
+ {
566
+ "type": "github",
567
+ "url": "https://github.com/sponsors/csstools"
568
+ },
569
+ {
570
+ "type": "opencollective",
571
+ "url": "https://opencollective.com/csstools"
572
+ }
573
+ ],
574
+ "license": "MIT",
575
+ "engines": {
576
+ "node": ">=20.19.0"
577
+ }
578
+ },
579
  "node_modules/@emnapi/core": {
580
  "version": "1.10.0",
581
  "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
 
753
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
754
  }
755
  },
756
+ "node_modules/@exodus/bytes": {
757
+ "version": "1.15.1",
758
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
759
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
760
+ "dev": true,
761
+ "license": "MIT",
762
+ "engines": {
763
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
764
+ },
765
+ "peerDependencies": {
766
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
767
+ },
768
+ "peerDependenciesMeta": {
769
+ "@noble/hashes": {
770
+ "optional": true
771
+ }
772
+ }
773
+ },
774
  "node_modules/@humanfs/core": {
775
  "version": "0.19.2",
776
  "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
 
1633
  "node": ">=8.0.0"
1634
  }
1635
  },
1636
+ "node_modules/@oxc-project/types": {
1637
+ "version": "0.139.0",
1638
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
1639
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1640
  "dev": true,
1641
  "license": "MIT",
1642
+ "funding": {
1643
+ "url": "https://github.com/sponsors/Boshen"
 
 
 
 
 
 
1644
  }
1645
  },
1646
+ "node_modules/@playwright/test": {
1647
+ "version": "1.61.1",
1648
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
1649
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
1650
+ "devOptional": true,
1651
+ "license": "Apache-2.0",
1652
+ "dependencies": {
1653
+ "playwright": "1.61.1"
1654
  },
1655
+ "bin": {
1656
+ "playwright": "cli.js"
1657
+ },
1658
+ "engines": {
1659
+ "node": ">=18"
 
 
 
 
 
 
 
 
1660
  }
1661
  },
1662
+ "node_modules/@rolldown/binding-android-arm64": {
1663
+ "version": "1.1.5",
1664
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
1665
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
1666
  "cpu": [
1667
  "arm64"
1668
  ],
 
1673
  "android"
1674
  ],
1675
  "engines": {
1676
+ "node": "^20.19.0 || >=22.12.0"
1677
  }
1678
  },
1679
+ "node_modules/@rolldown/binding-darwin-arm64": {
1680
+ "version": "1.1.5",
1681
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
1682
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
1683
  "cpu": [
1684
  "arm64"
1685
  ],
 
1690
  "darwin"
1691
  ],
1692
  "engines": {
1693
+ "node": "^20.19.0 || >=22.12.0"
1694
  }
1695
  },
1696
+ "node_modules/@rolldown/binding-darwin-x64": {
1697
+ "version": "1.1.5",
1698
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
1699
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
1700
  "cpu": [
1701
  "x64"
1702
  ],
 
1707
  "darwin"
1708
  ],
1709
  "engines": {
1710
+ "node": "^20.19.0 || >=22.12.0"
1711
  }
1712
  },
1713
+ "node_modules/@rolldown/binding-freebsd-x64": {
1714
+ "version": "1.1.5",
1715
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
1716
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
1717
  "cpu": [
1718
  "x64"
1719
  ],
 
1724
  "freebsd"
1725
  ],
1726
  "engines": {
1727
+ "node": "^20.19.0 || >=22.12.0"
1728
  }
1729
  },
1730
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
1731
+ "version": "1.1.5",
1732
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
1733
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
1734
  "cpu": [
1735
  "arm"
1736
  ],
 
1741
  "linux"
1742
  ],
1743
  "engines": {
1744
+ "node": "^20.19.0 || >=22.12.0"
1745
  }
1746
  },
1747
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
1748
+ "version": "1.1.5",
1749
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
1750
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
1751
  "cpu": [
1752
  "arm64"
1753
  ],
 
1761
  "linux"
1762
  ],
1763
  "engines": {
1764
+ "node": "^20.19.0 || >=22.12.0"
1765
  }
1766
  },
1767
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
1768
+ "version": "1.1.5",
1769
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
1770
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
1771
  "cpu": [
1772
  "arm64"
1773
  ],
 
1781
  "linux"
1782
  ],
1783
  "engines": {
1784
+ "node": "^20.19.0 || >=22.12.0"
1785
  }
1786
  },
1787
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
1788
+ "version": "1.1.5",
1789
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
1790
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
1791
  "cpu": [
1792
+ "ppc64"
1793
+ ],
1794
+ "dev": true,
1795
+ "libc": [
1796
+ "glibc"
1797
+ ],
1798
+ "license": "MIT",
1799
+ "optional": true,
1800
+ "os": [
1801
+ "linux"
1802
+ ],
1803
+ "engines": {
1804
+ "node": "^20.19.0 || >=22.12.0"
1805
+ }
1806
+ },
1807
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
1808
+ "version": "1.1.5",
1809
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
1810
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
1811
+ "cpu": [
1812
+ "s390x"
1813
+ ],
1814
+ "dev": true,
1815
+ "libc": [
1816
+ "glibc"
1817
+ ],
1818
+ "license": "MIT",
1819
+ "optional": true,
1820
+ "os": [
1821
+ "linux"
1822
+ ],
1823
+ "engines": {
1824
+ "node": "^20.19.0 || >=22.12.0"
1825
+ }
1826
+ },
1827
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
1828
+ "version": "1.1.5",
1829
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
1830
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
1831
+ "cpu": [
1832
+ "x64"
1833
+ ],
1834
+ "dev": true,
1835
+ "libc": [
1836
+ "glibc"
1837
+ ],
1838
+ "license": "MIT",
1839
+ "optional": true,
1840
+ "os": [
1841
+ "linux"
1842
+ ],
1843
+ "engines": {
1844
+ "node": "^20.19.0 || >=22.12.0"
1845
+ }
1846
+ },
1847
+ "node_modules/@rolldown/binding-linux-x64-musl": {
1848
+ "version": "1.1.5",
1849
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
1850
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
1851
+ "cpu": [
1852
+ "x64"
1853
+ ],
1854
+ "dev": true,
1855
+ "libc": [
1856
+ "musl"
1857
+ ],
1858
+ "license": "MIT",
1859
+ "optional": true,
1860
+ "os": [
1861
+ "linux"
1862
+ ],
1863
+ "engines": {
1864
+ "node": "^20.19.0 || >=22.12.0"
1865
+ }
1866
+ },
1867
+ "node_modules/@rolldown/binding-openharmony-arm64": {
1868
+ "version": "1.1.5",
1869
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
1870
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
1871
+ "cpu": [
1872
+ "arm64"
1873
+ ],
1874
+ "dev": true,
1875
+ "license": "MIT",
1876
+ "optional": true,
1877
+ "os": [
1878
+ "openharmony"
1879
+ ],
1880
+ "engines": {
1881
+ "node": "^20.19.0 || >=22.12.0"
1882
+ }
1883
+ },
1884
+ "node_modules/@rolldown/binding-wasm32-wasi": {
1885
+ "version": "1.1.5",
1886
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
1887
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
1888
+ "cpu": [
1889
+ "wasm32"
1890
+ ],
1891
+ "dev": true,
1892
+ "license": "MIT",
1893
+ "optional": true,
1894
+ "dependencies": {
1895
+ "@emnapi/core": "1.11.1",
1896
+ "@emnapi/runtime": "1.11.1",
1897
+ "@napi-rs/wasm-runtime": "^1.1.6"
1898
+ },
1899
+ "engines": {
1900
+ "node": "^20.19.0 || >=22.12.0"
1901
+ }
1902
+ },
1903
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
1904
+ "version": "1.11.1",
1905
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
1906
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
1907
+ "dev": true,
1908
+ "license": "MIT",
1909
+ "optional": true,
1910
+ "dependencies": {
1911
+ "@emnapi/wasi-threads": "1.2.2",
1912
+ "tslib": "^2.4.0"
1913
+ }
1914
+ },
1915
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
1916
+ "version": "1.11.1",
1917
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
1918
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
1919
+ "dev": true,
1920
+ "license": "MIT",
1921
+ "optional": true,
1922
+ "dependencies": {
1923
+ "tslib": "^2.4.0"
1924
+ }
1925
+ },
1926
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
1927
+ "version": "1.2.2",
1928
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
1929
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
1930
+ "dev": true,
1931
+ "license": "MIT",
1932
+ "optional": true,
1933
+ "dependencies": {
1934
+ "tslib": "^2.4.0"
1935
+ }
1936
+ },
1937
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
1938
+ "version": "1.1.5",
1939
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
1940
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
1941
+ "cpu": [
1942
+ "arm64"
1943
+ ],
1944
+ "dev": true,
1945
+ "license": "MIT",
1946
+ "optional": true,
1947
+ "os": [
1948
+ "win32"
1949
+ ],
1950
+ "engines": {
1951
+ "node": "^20.19.0 || >=22.12.0"
1952
+ }
1953
+ },
1954
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
1955
+ "version": "1.1.5",
1956
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
1957
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
1958
+ "cpu": [
1959
+ "x64"
1960
+ ],
1961
+ "dev": true,
1962
+ "license": "MIT",
1963
+ "optional": true,
1964
+ "os": [
1965
+ "win32"
1966
+ ],
1967
+ "engines": {
1968
+ "node": "^20.19.0 || >=22.12.0"
1969
+ }
1970
+ },
1971
+ "node_modules/@rolldown/pluginutils": {
1972
+ "version": "1.0.1",
1973
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
1974
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
1975
+ "dev": true,
1976
+ "license": "MIT"
1977
+ },
1978
+ "node_modules/@rtsao/scc": {
1979
+ "version": "1.1.0",
1980
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
1981
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
1982
+ "dev": true,
1983
+ "license": "MIT"
1984
+ },
1985
+ "node_modules/@standard-schema/spec": {
1986
+ "version": "1.1.0",
1987
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1988
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1989
+ "license": "MIT"
1990
+ },
1991
+ "node_modules/@swc/helpers": {
1992
+ "version": "0.5.15",
1993
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
1994
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
1995
+ "license": "Apache-2.0",
1996
+ "dependencies": {
1997
+ "tslib": "^2.8.0"
1998
+ }
1999
+ },
2000
+ "node_modules/@tailwindcss/node": {
2001
+ "version": "4.3.2",
2002
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
2003
+ "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
2004
+ "dev": true,
2005
+ "license": "MIT",
2006
+ "dependencies": {
2007
+ "@jridgewell/remapping": "^2.3.5",
2008
+ "enhanced-resolve": "5.21.6",
2009
+ "jiti": "^2.7.0",
2010
+ "lightningcss": "1.32.0",
2011
+ "magic-string": "^0.30.21",
2012
+ "source-map-js": "^1.2.1",
2013
+ "tailwindcss": "4.3.2"
2014
+ }
2015
+ },
2016
+ "node_modules/@tailwindcss/oxide": {
2017
+ "version": "4.3.2",
2018
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
2019
+ "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
2020
+ "dev": true,
2021
+ "license": "MIT",
2022
+ "engines": {
2023
+ "node": ">= 20"
2024
+ },
2025
+ "optionalDependencies": {
2026
+ "@tailwindcss/oxide-android-arm64": "4.3.2",
2027
+ "@tailwindcss/oxide-darwin-arm64": "4.3.2",
2028
+ "@tailwindcss/oxide-darwin-x64": "4.3.2",
2029
+ "@tailwindcss/oxide-freebsd-x64": "4.3.2",
2030
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
2031
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
2032
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
2033
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
2034
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
2035
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
2036
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
2037
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
2038
+ }
2039
+ },
2040
+ "node_modules/@tailwindcss/oxide-android-arm64": {
2041
+ "version": "4.3.2",
2042
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
2043
+ "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
2044
+ "cpu": [
2045
+ "arm64"
2046
+ ],
2047
+ "dev": true,
2048
+ "license": "MIT",
2049
+ "optional": true,
2050
+ "os": [
2051
+ "android"
2052
+ ],
2053
+ "engines": {
2054
+ "node": ">= 20"
2055
+ }
2056
+ },
2057
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
2058
+ "version": "4.3.2",
2059
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
2060
+ "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
2061
+ "cpu": [
2062
+ "arm64"
2063
+ ],
2064
+ "dev": true,
2065
+ "license": "MIT",
2066
+ "optional": true,
2067
+ "os": [
2068
+ "darwin"
2069
+ ],
2070
+ "engines": {
2071
+ "node": ">= 20"
2072
+ }
2073
+ },
2074
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
2075
+ "version": "4.3.2",
2076
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
2077
+ "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
2078
+ "cpu": [
2079
+ "x64"
2080
+ ],
2081
+ "dev": true,
2082
+ "license": "MIT",
2083
+ "optional": true,
2084
+ "os": [
2085
+ "darwin"
2086
+ ],
2087
+ "engines": {
2088
+ "node": ">= 20"
2089
+ }
2090
+ },
2091
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
2092
+ "version": "4.3.2",
2093
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
2094
+ "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
2095
+ "cpu": [
2096
+ "x64"
2097
+ ],
2098
+ "dev": true,
2099
+ "license": "MIT",
2100
+ "optional": true,
2101
+ "os": [
2102
+ "freebsd"
2103
+ ],
2104
+ "engines": {
2105
+ "node": ">= 20"
2106
+ }
2107
+ },
2108
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
2109
+ "version": "4.3.2",
2110
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
2111
+ "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
2112
+ "cpu": [
2113
+ "arm"
2114
+ ],
2115
+ "dev": true,
2116
+ "license": "MIT",
2117
+ "optional": true,
2118
+ "os": [
2119
+ "linux"
2120
+ ],
2121
+ "engines": {
2122
+ "node": ">= 20"
2123
+ }
2124
+ },
2125
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
2126
+ "version": "4.3.2",
2127
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
2128
+ "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
2129
+ "cpu": [
2130
+ "arm64"
2131
+ ],
2132
+ "dev": true,
2133
+ "libc": [
2134
+ "glibc"
2135
+ ],
2136
+ "license": "MIT",
2137
+ "optional": true,
2138
+ "os": [
2139
+ "linux"
2140
+ ],
2141
+ "engines": {
2142
+ "node": ">= 20"
2143
+ }
2144
+ },
2145
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
2146
+ "version": "4.3.2",
2147
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
2148
+ "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
2149
+ "cpu": [
2150
+ "arm64"
2151
+ ],
2152
+ "dev": true,
2153
+ "libc": [
2154
+ "musl"
2155
+ ],
2156
+ "license": "MIT",
2157
+ "optional": true,
2158
+ "os": [
2159
+ "linux"
2160
+ ],
2161
+ "engines": {
2162
+ "node": ">= 20"
2163
+ }
2164
+ },
2165
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
2166
+ "version": "4.3.2",
2167
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
2168
+ "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
2169
+ "cpu": [
2170
+ "x64"
2171
  ],
2172
  "dev": true,
2173
  "libc": [
 
2280
  "tailwindcss": "4.3.2"
2281
  }
2282
  },
2283
+ "node_modules/@testing-library/dom": {
2284
+ "version": "10.4.1",
2285
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
2286
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
2287
+ "dev": true,
2288
+ "license": "MIT",
2289
+ "peer": true,
2290
+ "dependencies": {
2291
+ "@babel/code-frame": "^7.10.4",
2292
+ "@babel/runtime": "^7.12.5",
2293
+ "@types/aria-query": "^5.0.1",
2294
+ "aria-query": "5.3.0",
2295
+ "dom-accessibility-api": "^0.5.9",
2296
+ "lz-string": "^1.5.0",
2297
+ "picocolors": "1.1.1",
2298
+ "pretty-format": "^27.0.2"
2299
+ },
2300
+ "engines": {
2301
+ "node": ">=18"
2302
+ }
2303
+ },
2304
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
2305
+ "version": "5.3.0",
2306
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
2307
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
2308
+ "dev": true,
2309
+ "license": "Apache-2.0",
2310
+ "peer": true,
2311
+ "dependencies": {
2312
+ "dequal": "^2.0.3"
2313
+ }
2314
+ },
2315
+ "node_modules/@testing-library/jest-dom": {
2316
+ "version": "6.9.1",
2317
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
2318
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
2319
+ "dev": true,
2320
+ "license": "MIT",
2321
+ "dependencies": {
2322
+ "@adobe/css-tools": "^4.4.0",
2323
+ "aria-query": "^5.0.0",
2324
+ "css.escape": "^1.5.1",
2325
+ "dom-accessibility-api": "^0.6.3",
2326
+ "picocolors": "^1.1.1",
2327
+ "redent": "^3.0.0"
2328
+ },
2329
+ "engines": {
2330
+ "node": ">=14",
2331
+ "npm": ">=6",
2332
+ "yarn": ">=1"
2333
+ }
2334
+ },
2335
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
2336
+ "version": "0.6.3",
2337
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
2338
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
2339
+ "dev": true,
2340
+ "license": "MIT"
2341
+ },
2342
+ "node_modules/@testing-library/react": {
2343
+ "version": "16.3.2",
2344
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
2345
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
2346
+ "dev": true,
2347
+ "license": "MIT",
2348
+ "dependencies": {
2349
+ "@babel/runtime": "^7.12.5"
2350
+ },
2351
+ "engines": {
2352
+ "node": ">=18"
2353
+ },
2354
+ "peerDependencies": {
2355
+ "@testing-library/dom": "^10.0.0",
2356
+ "@types/react": "^18.0.0 || ^19.0.0",
2357
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
2358
+ "react": "^18.0.0 || ^19.0.0",
2359
+ "react-dom": "^18.0.0 || ^19.0.0"
2360
+ },
2361
+ "peerDependenciesMeta": {
2362
+ "@types/react": {
2363
+ "optional": true
2364
+ },
2365
+ "@types/react-dom": {
2366
+ "optional": true
2367
+ }
2368
+ }
2369
+ },
2370
+ "node_modules/@testing-library/user-event": {
2371
+ "version": "14.6.1",
2372
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
2373
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
2374
+ "dev": true,
2375
+ "license": "MIT",
2376
+ "engines": {
2377
+ "node": ">=12",
2378
+ "npm": ">=6"
2379
+ },
2380
+ "peerDependencies": {
2381
+ "@testing-library/dom": ">=7.21.4"
2382
+ }
2383
+ },
2384
  "node_modules/@tybys/wasm-util": {
2385
  "version": "0.10.3",
2386
  "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
 
2392
  "tslib": "^2.4.0"
2393
  }
2394
  },
2395
+ "node_modules/@types/aria-query": {
2396
+ "version": "5.0.4",
2397
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
2398
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
2399
+ "dev": true,
2400
+ "license": "MIT",
2401
+ "peer": true
2402
+ },
2403
+ "node_modules/@types/chai": {
2404
+ "version": "5.2.3",
2405
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
2406
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
2407
+ "dev": true,
2408
+ "license": "MIT",
2409
+ "dependencies": {
2410
+ "@types/deep-eql": "*",
2411
+ "assertion-error": "^2.0.1"
2412
+ }
2413
+ },
2414
  "node_modules/@types/debug": {
2415
  "version": "4.1.13",
2416
  "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
 
2420
  "@types/ms": "*"
2421
  }
2422
  },
2423
+ "node_modules/@types/deep-eql": {
2424
+ "version": "4.0.2",
2425
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
2426
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
2427
+ "dev": true,
2428
+ "license": "MIT"
2429
+ },
2430
  "node_modules/@types/estree": {
2431
  "version": "1.0.9",
2432
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
 
3142
  "win32"
3143
  ]
3144
  },
3145
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
3146
+ "version": "1.12.2",
3147
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
3148
+ "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
3149
+ "cpu": [
3150
+ "ia32"
3151
+ ],
3152
+ "dev": true,
3153
+ "license": "MIT",
3154
+ "optional": true,
3155
+ "os": [
3156
+ "win32"
3157
+ ]
3158
+ },
3159
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
3160
+ "version": "1.12.2",
3161
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
3162
+ "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
3163
+ "cpu": [
3164
+ "x64"
3165
+ ],
3166
+ "dev": true,
3167
+ "license": "MIT",
3168
+ "optional": true,
3169
+ "os": [
3170
+ "win32"
3171
+ ]
3172
+ },
3173
+ "node_modules/@vercel/oidc": {
3174
+ "version": "3.2.0",
3175
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
3176
+ "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
3177
+ "license": "Apache-2.0",
3178
+ "engines": {
3179
+ "node": ">= 20"
3180
+ }
3181
+ },
3182
+ "node_modules/@vitest/expect": {
3183
+ "version": "4.1.10",
3184
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
3185
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
3186
+ "dev": true,
3187
+ "license": "MIT",
3188
+ "dependencies": {
3189
+ "@standard-schema/spec": "^1.1.0",
3190
+ "@types/chai": "^5.2.2",
3191
+ "@vitest/spy": "4.1.10",
3192
+ "@vitest/utils": "4.1.10",
3193
+ "chai": "^6.2.2",
3194
+ "tinyrainbow": "^3.1.0"
3195
+ },
3196
+ "funding": {
3197
+ "url": "https://opencollective.com/vitest"
3198
+ }
3199
+ },
3200
+ "node_modules/@vitest/mocker": {
3201
+ "version": "4.1.10",
3202
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
3203
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
3204
+ "dev": true,
3205
+ "license": "MIT",
3206
+ "dependencies": {
3207
+ "@vitest/spy": "4.1.10",
3208
+ "estree-walker": "^3.0.3",
3209
+ "magic-string": "^0.30.21"
3210
+ },
3211
+ "funding": {
3212
+ "url": "https://opencollective.com/vitest"
3213
+ },
3214
+ "peerDependencies": {
3215
+ "msw": "^2.4.9",
3216
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
3217
+ },
3218
+ "peerDependenciesMeta": {
3219
+ "msw": {
3220
+ "optional": true
3221
+ },
3222
+ "vite": {
3223
+ "optional": true
3224
+ }
3225
+ }
3226
+ },
3227
+ "node_modules/@vitest/pretty-format": {
3228
+ "version": "4.1.10",
3229
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
3230
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
3231
+ "dev": true,
3232
+ "license": "MIT",
3233
+ "dependencies": {
3234
+ "tinyrainbow": "^3.1.0"
3235
+ },
3236
+ "funding": {
3237
+ "url": "https://opencollective.com/vitest"
3238
+ }
3239
+ },
3240
+ "node_modules/@vitest/runner": {
3241
+ "version": "4.1.10",
3242
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
3243
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
3244
+ "dev": true,
3245
+ "license": "MIT",
3246
+ "dependencies": {
3247
+ "@vitest/utils": "4.1.10",
3248
+ "pathe": "^2.0.3"
3249
+ },
3250
+ "funding": {
3251
+ "url": "https://opencollective.com/vitest"
3252
+ }
3253
+ },
3254
+ "node_modules/@vitest/snapshot": {
3255
+ "version": "4.1.10",
3256
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
3257
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
3258
  "dev": true,
3259
  "license": "MIT",
3260
+ "dependencies": {
3261
+ "@vitest/pretty-format": "4.1.10",
3262
+ "@vitest/utils": "4.1.10",
3263
+ "magic-string": "^0.30.21",
3264
+ "pathe": "^2.0.3"
3265
+ },
3266
+ "funding": {
3267
+ "url": "https://opencollective.com/vitest"
3268
+ }
3269
  },
3270
+ "node_modules/@vitest/spy": {
3271
+ "version": "4.1.10",
3272
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
3273
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
 
 
 
3274
  "dev": true,
3275
  "license": "MIT",
3276
+ "funding": {
3277
+ "url": "https://opencollective.com/vitest"
3278
+ }
 
3279
  },
3280
+ "node_modules/@vitest/utils": {
3281
+ "version": "4.1.10",
3282
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
3283
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
3284
+ "dev": true,
3285
+ "license": "MIT",
3286
+ "dependencies": {
3287
+ "@vitest/pretty-format": "4.1.10",
3288
+ "convert-source-map": "^2.0.0",
3289
+ "tinyrainbow": "^3.1.0"
3290
+ },
3291
+ "funding": {
3292
+ "url": "https://opencollective.com/vitest"
3293
  }
3294
  },
3295
  "node_modules/acorn": {
 
3350
  "url": "https://github.com/sponsors/epoberezkin"
3351
  }
3352
  },
3353
+ "node_modules/ansi-regex": {
3354
+ "version": "5.0.1",
3355
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
3356
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
3357
+ "dev": true,
3358
+ "license": "MIT",
3359
+ "peer": true,
3360
+ "engines": {
3361
+ "node": ">=8"
3362
+ }
3363
+ },
3364
  "node_modules/ansi-styles": {
3365
  "version": "4.3.0",
3366
  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
 
3554
  "url": "https://github.com/sponsors/ljharb"
3555
  }
3556
  },
3557
+ "node_modules/assertion-error": {
3558
+ "version": "2.0.1",
3559
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
3560
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
3561
+ "dev": true,
3562
+ "license": "MIT",
3563
+ "engines": {
3564
+ "node": ">=12"
3565
+ }
3566
+ },
3567
  "node_modules/ast-types-flow": {
3568
  "version": "0.0.8",
3569
  "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
 
3646
  "node": ">=6.0.0"
3647
  }
3648
  },
3649
+ "node_modules/bidi-js": {
3650
+ "version": "1.0.3",
3651
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
3652
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
3653
+ "dev": true,
3654
+ "license": "MIT",
3655
+ "dependencies": {
3656
+ "require-from-string": "^2.0.2"
3657
+ }
3658
+ },
3659
  "node_modules/brace-expansion": {
3660
  "version": "1.1.15",
3661
  "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
 
3804
  "url": "https://github.com/sponsors/wooorm"
3805
  }
3806
  },
3807
+ "node_modules/chai": {
3808
+ "version": "6.2.2",
3809
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
3810
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
3811
+ "dev": true,
3812
+ "license": "MIT",
3813
+ "engines": {
3814
+ "node": ">=18"
3815
+ }
3816
+ },
3817
  "node_modules/chalk": {
3818
  "version": "4.1.2",
3819
  "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
 
3945
  "node": ">= 8"
3946
  }
3947
  },
3948
+ "node_modules/css-tree": {
3949
+ "version": "3.2.1",
3950
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
3951
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
3952
+ "dev": true,
3953
+ "license": "MIT",
3954
+ "dependencies": {
3955
+ "mdn-data": "2.27.1",
3956
+ "source-map-js": "^1.2.1"
3957
+ },
3958
+ "engines": {
3959
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
3960
+ }
3961
+ },
3962
+ "node_modules/css.escape": {
3963
+ "version": "1.5.1",
3964
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
3965
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
3966
+ "dev": true,
3967
+ "license": "MIT"
3968
+ },
3969
  "node_modules/csstype": {
3970
  "version": "3.2.3",
3971
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
 
3979
  "dev": true,
3980
  "license": "BSD-2-Clause"
3981
  },
3982
+ "node_modules/data-urls": {
3983
+ "version": "7.0.0",
3984
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
3985
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
3986
+ "dev": true,
3987
+ "license": "MIT",
3988
+ "dependencies": {
3989
+ "whatwg-mimetype": "^5.0.0",
3990
+ "whatwg-url": "^16.0.0"
3991
+ },
3992
+ "engines": {
3993
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
3994
+ }
3995
+ },
3996
  "node_modules/data-view-buffer": {
3997
  "version": "1.0.2",
3998
  "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
 
4064
  }
4065
  }
4066
  },
4067
+ "node_modules/decimal.js": {
4068
+ "version": "10.6.0",
4069
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
4070
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
4071
+ "dev": true,
4072
+ "license": "MIT"
4073
+ },
4074
  "node_modules/decode-named-character-reference": {
4075
  "version": "1.3.0",
4076
  "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
 
4172
  "node": ">=0.10.0"
4173
  }
4174
  },
4175
+ "node_modules/dom-accessibility-api": {
4176
+ "version": "0.5.16",
4177
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
4178
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
4179
+ "dev": true,
4180
+ "license": "MIT",
4181
+ "peer": true
4182
+ },
4183
  "node_modules/dunder-proto": {
4184
  "version": "1.0.1",
4185
  "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
 
4223
  "node": ">=10.13.0"
4224
  }
4225
  },
4226
+ "node_modules/entities": {
4227
+ "version": "8.0.0",
4228
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
4229
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
4230
+ "dev": true,
4231
+ "license": "BSD-2-Clause",
4232
+ "engines": {
4233
+ "node": ">=20.19.0"
4234
+ },
4235
+ "funding": {
4236
+ "url": "https://github.com/fb55/entities?sponsor=1"
4237
+ }
4238
+ },
4239
  "node_modules/es-abstract": {
4240
  "version": "1.24.2",
4241
  "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
 
4372
  "node": ">= 0.4"
4373
  }
4374
  },
4375
+ "node_modules/es-module-lexer": {
4376
+ "version": "2.3.1",
4377
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
4378
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
4379
+ "dev": true,
4380
+ "license": "MIT"
4381
+ },
4382
  "node_modules/es-object-atoms": {
4383
  "version": "1.1.2",
4384
  "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
 
4871
  "url": "https://opencollective.com/unified"
4872
  }
4873
  },
4874
+ "node_modules/estree-walker": {
4875
+ "version": "3.0.3",
4876
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
4877
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
4878
+ "dev": true,
4879
+ "license": "MIT",
4880
+ "dependencies": {
4881
+ "@types/estree": "^1.0.0"
4882
+ }
4883
+ },
4884
  "node_modules/esutils": {
4885
  "version": "2.0.3",
4886
  "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
 
4900
  "node": ">=18.0.0"
4901
  }
4902
  },
4903
+ "node_modules/expect-type": {
4904
+ "version": "1.4.0",
4905
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
4906
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
4907
+ "dev": true,
4908
+ "license": "Apache-2.0",
4909
+ "engines": {
4910
+ "node": ">=12.0.0"
4911
+ }
4912
+ },
4913
  "node_modules/extend": {
4914
  "version": "3.0.2",
4915
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
 
5057
  "url": "https://github.com/sponsors/ljharb"
5058
  }
5059
  },
5060
+ "node_modules/fsevents": {
5061
+ "version": "2.3.3",
5062
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
5063
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
5064
+ "dev": true,
5065
+ "hasInstallScript": true,
5066
+ "license": "MIT",
5067
+ "optional": true,
5068
+ "os": [
5069
+ "darwin"
5070
+ ],
5071
+ "engines": {
5072
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
5073
+ }
5074
+ },
5075
  "node_modules/function-bind": {
5076
  "version": "1.1.2",
5077
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
 
5458
  "node": ">=12.0.0"
5459
  }
5460
  },
5461
+ "node_modules/html-encoding-sniffer": {
5462
+ "version": "6.0.0",
5463
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
5464
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
5465
+ "dev": true,
5466
+ "license": "MIT",
5467
+ "dependencies": {
5468
+ "@exodus/bytes": "^1.6.0"
5469
+ },
5470
+ "engines": {
5471
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
5472
+ }
5473
+ },
5474
  "node_modules/html-url-attributes": {
5475
  "version": "3.0.1",
5476
  "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
 
5518
  "node": ">=0.8.19"
5519
  }
5520
  },
5521
+ "node_modules/indent-string": {
5522
+ "version": "4.0.0",
5523
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
5524
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
5525
+ "dev": true,
5526
+ "license": "MIT",
5527
+ "engines": {
5528
+ "node": ">=8"
5529
+ }
5530
+ },
5531
  "node_modules/inline-style-parser": {
5532
  "version": "0.2.7",
5533
  "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
 
5891
  "url": "https://github.com/sponsors/sindresorhus"
5892
  }
5893
  },
5894
+ "node_modules/is-potential-custom-element-name": {
5895
+ "version": "1.0.1",
5896
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
5897
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
5898
+ "dev": true,
5899
+ "license": "MIT"
5900
+ },
5901
  "node_modules/is-regex": {
5902
  "version": "1.2.1",
5903
  "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
 
6115
  "js-yaml": "bin/js-yaml.js"
6116
  }
6117
  },
6118
+ "node_modules/jsdom": {
6119
+ "version": "29.1.1",
6120
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
6121
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
6122
+ "dev": true,
6123
+ "license": "MIT",
6124
+ "dependencies": {
6125
+ "@asamuzakjp/css-color": "^5.1.11",
6126
+ "@asamuzakjp/dom-selector": "^7.1.1",
6127
+ "@bramus/specificity": "^2.4.2",
6128
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
6129
+ "@exodus/bytes": "^1.15.0",
6130
+ "css-tree": "^3.2.1",
6131
+ "data-urls": "^7.0.0",
6132
+ "decimal.js": "^10.6.0",
6133
+ "html-encoding-sniffer": "^6.0.0",
6134
+ "is-potential-custom-element-name": "^1.0.1",
6135
+ "lru-cache": "^11.3.5",
6136
+ "parse5": "^8.0.1",
6137
+ "saxes": "^6.0.0",
6138
+ "symbol-tree": "^3.2.4",
6139
+ "tough-cookie": "^6.0.1",
6140
+ "undici": "^7.25.0",
6141
+ "w3c-xmlserializer": "^5.0.0",
6142
+ "webidl-conversions": "^8.0.1",
6143
+ "whatwg-mimetype": "^5.0.0",
6144
+ "whatwg-url": "^16.0.1",
6145
+ "xml-name-validator": "^5.0.0"
6146
+ },
6147
+ "engines": {
6148
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
6149
+ },
6150
+ "peerDependencies": {
6151
+ "canvas": "^3.0.0"
6152
+ },
6153
+ "peerDependenciesMeta": {
6154
+ "canvas": {
6155
+ "optional": true
6156
+ }
6157
+ }
6158
+ },
6159
+ "node_modules/jsdom/node_modules/lru-cache": {
6160
+ "version": "11.5.2",
6161
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
6162
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
6163
+ "dev": true,
6164
+ "license": "BlueOak-1.0.0",
6165
+ "engines": {
6166
+ "node": "20 || >=22"
6167
+ }
6168
+ },
6169
  "node_modules/jsesc": {
6170
  "version": "3.1.0",
6171
  "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
 
6632
  "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
6633
  }
6634
  },
6635
+ "node_modules/lz-string": {
6636
+ "version": "1.5.0",
6637
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
6638
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
6639
+ "dev": true,
6640
+ "license": "MIT",
6641
+ "peer": true,
6642
+ "bin": {
6643
+ "lz-string": "bin/bin.js"
6644
+ }
6645
+ },
6646
  "node_modules/magic-string": {
6647
  "version": "0.30.21",
6648
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
 
6955
  "url": "https://opencollective.com/unified"
6956
  }
6957
  },
6958
+ "node_modules/mdn-data": {
6959
+ "version": "2.27.1",
6960
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
6961
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
6962
+ "dev": true,
6963
+ "license": "CC0-1.0"
6964
+ },
6965
  "node_modules/merge2": {
6966
  "version": "1.4.1",
6967
  "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
 
7549
  "node": ">=8.6"
7550
  }
7551
  },
7552
+ "node_modules/min-indent": {
7553
+ "version": "1.0.1",
7554
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
7555
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
7556
+ "dev": true,
7557
+ "license": "MIT",
7558
+ "engines": {
7559
+ "node": ">=4"
7560
+ }
7561
+ },
7562
  "node_modules/minimatch": {
7563
  "version": "3.1.5",
7564
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
 
7862
  "url": "https://github.com/sponsors/ljharb"
7863
  }
7864
  },
7865
+ "node_modules/obug": {
7866
+ "version": "2.1.4",
7867
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
7868
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
7869
+ "dev": true,
7870
+ "funding": [
7871
+ "https://github.com/sponsors/sxzz",
7872
+ "https://opencollective.com/debug"
7873
+ ],
7874
+ "license": "MIT",
7875
+ "engines": {
7876
+ "node": ">=12.20.0"
7877
+ }
7878
+ },
7879
  "node_modules/optionator": {
7880
  "version": "0.9.4",
7881
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
 
7982
  "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
7983
  "license": "MIT"
7984
  },
7985
+ "node_modules/parse5": {
7986
+ "version": "8.0.1",
7987
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
7988
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
7989
+ "dev": true,
7990
+ "license": "MIT",
7991
+ "dependencies": {
7992
+ "entities": "^8.0.0"
7993
+ },
7994
+ "funding": {
7995
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
7996
+ }
7997
+ },
7998
  "node_modules/path-exists": {
7999
  "version": "4.0.0",
8000
  "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
 
8022
  "dev": true,
8023
  "license": "MIT"
8024
  },
8025
+ "node_modules/pathe": {
8026
+ "version": "2.0.3",
8027
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
8028
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
8029
+ "dev": true,
8030
+ "license": "MIT"
8031
+ },
8032
  "node_modules/picocolors": {
8033
  "version": "1.1.1",
8034
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
 
8048
  "url": "https://github.com/sponsors/jonschlinkert"
8049
  }
8050
  },
8051
+ "node_modules/playwright": {
8052
+ "version": "1.61.1",
8053
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
8054
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
8055
+ "devOptional": true,
8056
+ "license": "Apache-2.0",
8057
+ "dependencies": {
8058
+ "playwright-core": "1.61.1"
8059
+ },
8060
+ "bin": {
8061
+ "playwright": "cli.js"
8062
+ },
8063
+ "engines": {
8064
+ "node": ">=18"
8065
+ },
8066
+ "optionalDependencies": {
8067
+ "fsevents": "2.3.2"
8068
+ }
8069
+ },
8070
+ "node_modules/playwright-core": {
8071
+ "version": "1.61.1",
8072
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
8073
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
8074
+ "devOptional": true,
8075
+ "license": "Apache-2.0",
8076
+ "bin": {
8077
+ "playwright-core": "cli.js"
8078
+ },
8079
+ "engines": {
8080
+ "node": ">=18"
8081
+ }
8082
+ },
8083
+ "node_modules/playwright/node_modules/fsevents": {
8084
+ "version": "2.3.2",
8085
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
8086
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
8087
+ "hasInstallScript": true,
8088
+ "license": "MIT",
8089
+ "optional": true,
8090
+ "os": [
8091
+ "darwin"
8092
+ ],
8093
+ "engines": {
8094
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
8095
+ }
8096
+ },
8097
  "node_modules/possible-typed-array-names": {
8098
  "version": "1.1.0",
8099
  "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
 
8105
  }
8106
  },
8107
  "node_modules/postcss": {
8108
+ "version": "8.5.19",
8109
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
8110
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
8111
  "dev": true,
8112
  "funding": [
8113
  {
 
8143
  "node": ">= 0.8.0"
8144
  }
8145
  },
8146
+ "node_modules/pretty-format": {
8147
+ "version": "27.5.1",
8148
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
8149
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
8150
+ "dev": true,
8151
+ "license": "MIT",
8152
+ "peer": true,
8153
+ "dependencies": {
8154
+ "ansi-regex": "^5.0.1",
8155
+ "ansi-styles": "^5.0.0",
8156
+ "react-is": "^17.0.1"
8157
+ },
8158
+ "engines": {
8159
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
8160
+ }
8161
+ },
8162
+ "node_modules/pretty-format/node_modules/ansi-styles": {
8163
+ "version": "5.2.0",
8164
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
8165
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
8166
+ "dev": true,
8167
+ "license": "MIT",
8168
+ "peer": true,
8169
+ "engines": {
8170
+ "node": ">=10"
8171
+ },
8172
+ "funding": {
8173
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
8174
+ }
8175
+ },
8176
+ "node_modules/pretty-format/node_modules/react-is": {
8177
+ "version": "17.0.2",
8178
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
8179
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
8180
+ "dev": true,
8181
+ "license": "MIT",
8182
+ "peer": true
8183
+ },
8184
  "node_modules/prop-types": {
8185
  "version": "15.8.1",
8186
  "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
 
8289
  "react": ">=18"
8290
  }
8291
  },
8292
+ "node_modules/redent": {
8293
+ "version": "3.0.0",
8294
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
8295
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
8296
+ "dev": true,
8297
+ "license": "MIT",
8298
+ "dependencies": {
8299
+ "indent-string": "^4.0.0",
8300
+ "strip-indent": "^3.0.0"
8301
+ },
8302
+ "engines": {
8303
+ "node": ">=8"
8304
+ }
8305
+ },
8306
  "node_modules/reflect.getprototypeof": {
8307
  "version": "1.0.10",
8308
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
 
8430
  "url": "https://opencollective.com/unified"
8431
  }
8432
  },
8433
+ "node_modules/require-from-string": {
8434
+ "version": "2.0.2",
8435
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
8436
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
8437
+ "dev": true,
8438
+ "license": "MIT",
8439
+ "engines": {
8440
+ "node": ">=0.10.0"
8441
+ }
8442
+ },
8443
  "node_modules/resolve": {
8444
  "version": "2.0.0-next.7",
8445
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
 
8495
  "node": ">=0.10.0"
8496
  }
8497
  },
8498
+ "node_modules/rolldown": {
8499
+ "version": "1.1.5",
8500
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
8501
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
8502
+ "dev": true,
8503
+ "license": "MIT",
8504
+ "dependencies": {
8505
+ "@oxc-project/types": "=0.139.0",
8506
+ "@rolldown/pluginutils": "^1.0.0"
8507
+ },
8508
+ "bin": {
8509
+ "rolldown": "bin/cli.mjs"
8510
+ },
8511
+ "engines": {
8512
+ "node": "^20.19.0 || >=22.12.0"
8513
+ },
8514
+ "optionalDependencies": {
8515
+ "@rolldown/binding-android-arm64": "1.1.5",
8516
+ "@rolldown/binding-darwin-arm64": "1.1.5",
8517
+ "@rolldown/binding-darwin-x64": "1.1.5",
8518
+ "@rolldown/binding-freebsd-x64": "1.1.5",
8519
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
8520
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
8521
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
8522
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
8523
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
8524
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
8525
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
8526
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
8527
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
8528
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
8529
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
8530
+ }
8531
+ },
8532
  "node_modules/run-parallel": {
8533
  "version": "1.2.0",
8534
  "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
 
8608
  "url": "https://github.com/sponsors/ljharb"
8609
  }
8610
  },
8611
+ "node_modules/saxes": {
8612
+ "version": "6.0.0",
8613
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
8614
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
8615
+ "dev": true,
8616
+ "license": "ISC",
8617
+ "dependencies": {
8618
+ "xmlchars": "^2.2.0"
8619
+ },
8620
+ "engines": {
8621
+ "node": ">=v12.22.7"
8622
+ }
8623
+ },
8624
  "node_modules/scheduler": {
8625
  "version": "0.27.0",
8626
  "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
 
8843
  "url": "https://github.com/sponsors/ljharb"
8844
  }
8845
  },
8846
+ "node_modules/siginfo": {
8847
+ "version": "2.0.0",
8848
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
8849
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
8850
+ "dev": true,
8851
+ "license": "ISC"
8852
+ },
8853
  "node_modules/source-map-js": {
8854
  "version": "1.2.1",
8855
  "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
 
8876
  "dev": true,
8877
  "license": "MIT"
8878
  },
8879
+ "node_modules/stackback": {
8880
+ "version": "0.0.2",
8881
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
8882
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
8883
+ "dev": true,
8884
+ "license": "MIT"
8885
+ },
8886
+ "node_modules/std-env": {
8887
+ "version": "4.2.0",
8888
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
8889
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
8890
+ "dev": true,
8891
+ "license": "MIT"
8892
+ },
8893
  "node_modules/stop-iteration-iterator": {
8894
  "version": "1.1.0",
8895
  "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
 
9042
  "node": ">=4"
9043
  }
9044
  },
9045
+ "node_modules/strip-indent": {
9046
+ "version": "3.0.0",
9047
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
9048
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
9049
+ "dev": true,
9050
+ "license": "MIT",
9051
+ "dependencies": {
9052
+ "min-indent": "^1.0.0"
9053
+ },
9054
+ "engines": {
9055
+ "node": ">=8"
9056
+ }
9057
+ },
9058
  "node_modules/strip-json-comments": {
9059
  "version": "3.1.1",
9060
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
 
9148
  "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
9149
  }
9150
  },
9151
+ "node_modules/symbol-tree": {
9152
+ "version": "3.2.4",
9153
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
9154
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
9155
+ "dev": true,
9156
+ "license": "MIT"
9157
+ },
9158
  "node_modules/tailwindcss": {
9159
  "version": "4.3.2",
9160
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
 
9188
  "url": "https://github.com/sponsors/sindresorhus"
9189
  }
9190
  },
9191
+ "node_modules/tinybench": {
9192
+ "version": "2.9.0",
9193
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
9194
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
9195
+ "dev": true,
9196
+ "license": "MIT"
9197
+ },
9198
+ "node_modules/tinyexec": {
9199
+ "version": "1.2.4",
9200
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
9201
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
9202
+ "dev": true,
9203
+ "license": "MIT",
9204
+ "engines": {
9205
+ "node": ">=18"
9206
+ }
9207
+ },
9208
  "node_modules/tinyglobby": {
9209
  "version": "0.2.17",
9210
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
 
9253
  "url": "https://github.com/sponsors/jonschlinkert"
9254
  }
9255
  },
9256
+ "node_modules/tinyrainbow": {
9257
+ "version": "3.1.0",
9258
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
9259
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
9260
+ "dev": true,
9261
+ "license": "MIT",
9262
+ "engines": {
9263
+ "node": ">=14.0.0"
9264
+ }
9265
+ },
9266
+ "node_modules/tldts": {
9267
+ "version": "7.4.9",
9268
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
9269
+ "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
9270
+ "dev": true,
9271
+ "license": "MIT",
9272
+ "dependencies": {
9273
+ "tldts-core": "^7.4.9"
9274
+ },
9275
+ "bin": {
9276
+ "tldts": "bin/cli.js"
9277
+ }
9278
+ },
9279
+ "node_modules/tldts-core": {
9280
+ "version": "7.4.9",
9281
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
9282
+ "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
9283
+ "dev": true,
9284
+ "license": "MIT"
9285
+ },
9286
  "node_modules/to-regex-range": {
9287
  "version": "5.0.1",
9288
  "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
 
9296
  "node": ">=8.0"
9297
  }
9298
  },
9299
+ "node_modules/tough-cookie": {
9300
+ "version": "6.0.2",
9301
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
9302
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
9303
+ "dev": true,
9304
+ "license": "BSD-3-Clause",
9305
+ "dependencies": {
9306
+ "tldts": "^7.0.5"
9307
+ },
9308
+ "engines": {
9309
+ "node": ">=16"
9310
+ }
9311
+ },
9312
+ "node_modules/tr46": {
9313
+ "version": "6.0.0",
9314
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
9315
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
9316
+ "dev": true,
9317
+ "license": "MIT",
9318
+ "dependencies": {
9319
+ "punycode": "^2.3.1"
9320
+ },
9321
+ "engines": {
9322
+ "node": ">=20"
9323
+ }
9324
+ },
9325
  "node_modules/trim-lines": {
9326
  "version": "3.0.1",
9327
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
 
9535
  "url": "https://github.com/sponsors/ljharb"
9536
  }
9537
  },
9538
+ "node_modules/undici": {
9539
+ "version": "7.28.0",
9540
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
9541
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
9542
+ "dev": true,
9543
+ "license": "MIT",
9544
+ "engines": {
9545
+ "node": ">=20.18.1"
9546
+ }
9547
+ },
9548
  "node_modules/undici-types": {
9549
  "version": "7.18.2",
9550
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
 
9769
  "url": "https://opencollective.com/unified"
9770
  }
9771
  },
9772
+ "node_modules/vite": {
9773
+ "version": "8.1.5",
9774
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
9775
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
9776
+ "dev": true,
9777
+ "license": "MIT",
9778
+ "dependencies": {
9779
+ "lightningcss": "^1.32.0",
9780
+ "picomatch": "^4.0.5",
9781
+ "postcss": "^8.5.17",
9782
+ "rolldown": "~1.1.5",
9783
+ "tinyglobby": "^0.2.17"
9784
+ },
9785
+ "bin": {
9786
+ "vite": "bin/vite.js"
9787
+ },
9788
+ "engines": {
9789
+ "node": "^20.19.0 || >=22.12.0"
9790
+ },
9791
+ "funding": {
9792
+ "url": "https://github.com/vitejs/vite?sponsor=1"
9793
+ },
9794
+ "optionalDependencies": {
9795
+ "fsevents": "~2.3.3"
9796
+ },
9797
+ "peerDependencies": {
9798
+ "@types/node": "^20.19.0 || >=22.12.0",
9799
+ "@vitejs/devtools": "^0.3.0",
9800
+ "esbuild": "^0.27.0 || ^0.28.0",
9801
+ "jiti": ">=1.21.0",
9802
+ "less": "^4.0.0",
9803
+ "sass": "^1.70.0",
9804
+ "sass-embedded": "^1.70.0",
9805
+ "stylus": ">=0.54.8",
9806
+ "sugarss": "^5.0.0",
9807
+ "terser": "^5.16.0",
9808
+ "tsx": "^4.8.1",
9809
+ "yaml": "^2.4.2"
9810
+ },
9811
+ "peerDependenciesMeta": {
9812
+ "@types/node": {
9813
+ "optional": true
9814
+ },
9815
+ "@vitejs/devtools": {
9816
+ "optional": true
9817
+ },
9818
+ "esbuild": {
9819
+ "optional": true
9820
+ },
9821
+ "jiti": {
9822
+ "optional": true
9823
+ },
9824
+ "less": {
9825
+ "optional": true
9826
+ },
9827
+ "sass": {
9828
+ "optional": true
9829
+ },
9830
+ "sass-embedded": {
9831
+ "optional": true
9832
+ },
9833
+ "stylus": {
9834
+ "optional": true
9835
+ },
9836
+ "sugarss": {
9837
+ "optional": true
9838
+ },
9839
+ "terser": {
9840
+ "optional": true
9841
+ },
9842
+ "tsx": {
9843
+ "optional": true
9844
+ },
9845
+ "yaml": {
9846
+ "optional": true
9847
+ }
9848
+ }
9849
+ },
9850
+ "node_modules/vite/node_modules/picomatch": {
9851
+ "version": "4.0.5",
9852
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
9853
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
9854
+ "dev": true,
9855
+ "license": "MIT",
9856
+ "engines": {
9857
+ "node": ">=12"
9858
+ },
9859
+ "funding": {
9860
+ "url": "https://github.com/sponsors/jonschlinkert"
9861
+ }
9862
+ },
9863
+ "node_modules/vitest": {
9864
+ "version": "4.1.10",
9865
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
9866
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
9867
+ "dev": true,
9868
+ "license": "MIT",
9869
+ "dependencies": {
9870
+ "@vitest/expect": "4.1.10",
9871
+ "@vitest/mocker": "4.1.10",
9872
+ "@vitest/pretty-format": "4.1.10",
9873
+ "@vitest/runner": "4.1.10",
9874
+ "@vitest/snapshot": "4.1.10",
9875
+ "@vitest/spy": "4.1.10",
9876
+ "@vitest/utils": "4.1.10",
9877
+ "es-module-lexer": "^2.0.0",
9878
+ "expect-type": "^1.3.0",
9879
+ "magic-string": "^0.30.21",
9880
+ "obug": "^2.1.1",
9881
+ "pathe": "^2.0.3",
9882
+ "picomatch": "^4.0.3",
9883
+ "std-env": "^4.0.0-rc.1",
9884
+ "tinybench": "^2.9.0",
9885
+ "tinyexec": "^1.0.2",
9886
+ "tinyglobby": "^0.2.15",
9887
+ "tinyrainbow": "^3.1.0",
9888
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
9889
+ "why-is-node-running": "^2.3.0"
9890
+ },
9891
+ "bin": {
9892
+ "vitest": "vitest.mjs"
9893
+ },
9894
+ "engines": {
9895
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
9896
+ },
9897
+ "funding": {
9898
+ "url": "https://opencollective.com/vitest"
9899
+ },
9900
+ "peerDependencies": {
9901
+ "@edge-runtime/vm": "*",
9902
+ "@opentelemetry/api": "^1.9.0",
9903
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
9904
+ "@vitest/browser-playwright": "4.1.10",
9905
+ "@vitest/browser-preview": "4.1.10",
9906
+ "@vitest/browser-webdriverio": "4.1.10",
9907
+ "@vitest/coverage-istanbul": "4.1.10",
9908
+ "@vitest/coverage-v8": "4.1.10",
9909
+ "@vitest/ui": "4.1.10",
9910
+ "happy-dom": "*",
9911
+ "jsdom": "*",
9912
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
9913
+ },
9914
+ "peerDependenciesMeta": {
9915
+ "@edge-runtime/vm": {
9916
+ "optional": true
9917
+ },
9918
+ "@opentelemetry/api": {
9919
+ "optional": true
9920
+ },
9921
+ "@types/node": {
9922
+ "optional": true
9923
+ },
9924
+ "@vitest/browser-playwright": {
9925
+ "optional": true
9926
+ },
9927
+ "@vitest/browser-preview": {
9928
+ "optional": true
9929
+ },
9930
+ "@vitest/browser-webdriverio": {
9931
+ "optional": true
9932
+ },
9933
+ "@vitest/coverage-istanbul": {
9934
+ "optional": true
9935
+ },
9936
+ "@vitest/coverage-v8": {
9937
+ "optional": true
9938
+ },
9939
+ "@vitest/ui": {
9940
+ "optional": true
9941
+ },
9942
+ "happy-dom": {
9943
+ "optional": true
9944
+ },
9945
+ "jsdom": {
9946
+ "optional": true
9947
+ },
9948
+ "vite": {
9949
+ "optional": false
9950
+ }
9951
+ }
9952
+ },
9953
+ "node_modules/vitest/node_modules/picomatch": {
9954
+ "version": "4.0.5",
9955
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
9956
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
9957
+ "dev": true,
9958
+ "license": "MIT",
9959
+ "engines": {
9960
+ "node": ">=12"
9961
+ },
9962
+ "funding": {
9963
+ "url": "https://github.com/sponsors/jonschlinkert"
9964
+ }
9965
+ },
9966
+ "node_modules/w3c-xmlserializer": {
9967
+ "version": "5.0.0",
9968
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
9969
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
9970
+ "dev": true,
9971
+ "license": "MIT",
9972
+ "dependencies": {
9973
+ "xml-name-validator": "^5.0.0"
9974
+ },
9975
+ "engines": {
9976
+ "node": ">=18"
9977
+ }
9978
+ },
9979
+ "node_modules/webidl-conversions": {
9980
+ "version": "8.0.1",
9981
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
9982
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
9983
+ "dev": true,
9984
+ "license": "BSD-2-Clause",
9985
+ "engines": {
9986
+ "node": ">=20"
9987
+ }
9988
+ },
9989
+ "node_modules/whatwg-mimetype": {
9990
+ "version": "5.0.0",
9991
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
9992
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
9993
+ "dev": true,
9994
+ "license": "MIT",
9995
+ "engines": {
9996
+ "node": ">=20"
9997
+ }
9998
+ },
9999
+ "node_modules/whatwg-url": {
10000
+ "version": "16.0.1",
10001
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
10002
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
10003
+ "dev": true,
10004
+ "license": "MIT",
10005
+ "dependencies": {
10006
+ "@exodus/bytes": "^1.11.0",
10007
+ "tr46": "^6.0.0",
10008
+ "webidl-conversions": "^8.0.1"
10009
+ },
10010
+ "engines": {
10011
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
10012
+ }
10013
+ },
10014
  "node_modules/which": {
10015
  "version": "2.0.2",
10016
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
 
10116
  "url": "https://github.com/sponsors/ljharb"
10117
  }
10118
  },
10119
+ "node_modules/why-is-node-running": {
10120
+ "version": "2.3.0",
10121
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
10122
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
10123
+ "dev": true,
10124
+ "license": "MIT",
10125
+ "dependencies": {
10126
+ "siginfo": "^2.0.0",
10127
+ "stackback": "0.0.2"
10128
+ },
10129
+ "bin": {
10130
+ "why-is-node-running": "cli.js"
10131
+ },
10132
+ "engines": {
10133
+ "node": ">=8"
10134
+ }
10135
+ },
10136
  "node_modules/word-wrap": {
10137
  "version": "1.2.5",
10138
  "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
 
10143
  "node": ">=0.10.0"
10144
  }
10145
  },
10146
+ "node_modules/xml-name-validator": {
10147
+ "version": "5.0.0",
10148
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
10149
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
10150
+ "dev": true,
10151
+ "license": "Apache-2.0",
10152
+ "engines": {
10153
+ "node": ">=18"
10154
+ }
10155
+ },
10156
+ "node_modules/xmlchars": {
10157
+ "version": "2.2.0",
10158
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
10159
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
10160
+ "dev": true,
10161
+ "license": "MIT"
10162
+ },
10163
  "node_modules/yallist": {
10164
  "version": "3.1.1",
10165
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
frontend/package.json CHANGED
@@ -8,7 +8,11 @@
8
  "dev": "next dev",
9
  "build": "next build",
10
  "start": "next start",
11
- "test": "node --test lib/chat-transport.test.mts",
 
 
 
 
12
  "lint": "eslint ."
13
  },
14
  "dependencies": {
@@ -26,13 +30,19 @@
26
  },
27
  "devDependencies": {
28
  "@eslint/eslintrc": "^3.3.1",
 
29
  "@tailwindcss/postcss": "^4.1.17",
 
 
 
30
  "@types/node": "^24.10.1",
31
  "@types/react": "^19.2.2",
32
  "@types/react-dom": "^19.2.2",
33
  "eslint": "^9.39.4",
34
  "eslint-config-next": "^16.2.3",
 
35
  "tailwindcss": "^4.2.2",
36
- "typescript": "^6.0.3"
 
37
  }
38
  }
 
8
  "dev": "next dev",
9
  "build": "next build",
10
  "start": "next start",
11
+ "test": "npm run test:unit && npm run test:components",
12
+ "test:unit": "node --test lib/*.test.mts",
13
+ "test:components": "vitest run",
14
+ "test:e2e": "playwright test",
15
+ "test:watch": "vitest",
16
  "lint": "eslint ."
17
  },
18
  "dependencies": {
 
30
  },
31
  "devDependencies": {
32
  "@eslint/eslintrc": "^3.3.1",
33
+ "@playwright/test": "^1.61.1",
34
  "@tailwindcss/postcss": "^4.1.17",
35
+ "@testing-library/jest-dom": "^6.9.1",
36
+ "@testing-library/react": "^16.3.2",
37
+ "@testing-library/user-event": "^14.6.1",
38
  "@types/node": "^24.10.1",
39
  "@types/react": "^19.2.2",
40
  "@types/react-dom": "^19.2.2",
41
  "eslint": "^9.39.4",
42
  "eslint-config-next": "^16.2.3",
43
+ "jsdom": "^29.1.1",
44
  "tailwindcss": "^4.2.2",
45
+ "typescript": "^6.0.3",
46
+ "vitest": "^4.1.10"
47
  }
48
  }
frontend/playwright.config.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ export default defineConfig({
4
+ testDir: "./e2e",
5
+ outputDir: "../output/playwright/test-results",
6
+ fullyParallel: false,
7
+ forbidOnly: Boolean(process.env.CI),
8
+ retries: process.env.CI ? 1 : 0,
9
+ reporter: process.env.CI ? "github" : "list",
10
+ use: {
11
+ baseURL: "http://127.0.0.1:3100",
12
+ screenshot: "only-on-failure",
13
+ trace: "retain-on-failure",
14
+ },
15
+ projects: [
16
+ {
17
+ name: "chromium",
18
+ use: { ...devices["Desktop Chrome"] },
19
+ },
20
+ ],
21
+ webServer: {
22
+ command: "python3 -m http.server 3100 --bind 127.0.0.1 --directory out",
23
+ url: "http://127.0.0.1:3100",
24
+ reuseExistingServer: !process.env.CI,
25
+ timeout: 120_000,
26
+ },
27
+ });
frontend/test/setup.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "@testing-library/jest-dom/vitest";
2
+
3
+ import { cleanup } from "@testing-library/react";
4
+ import { afterEach, vi } from "vitest";
5
+
6
+ afterEach(() => {
7
+ cleanup();
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ if (!HTMLElement.prototype.scrollIntoView) {
12
+ HTMLElement.prototype.scrollIntoView = vi.fn();
13
+ }
frontend/vitest.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fileURLToPath, URL } from "node:url";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ "@": fileURLToPath(new URL(".", import.meta.url)),
8
+ },
9
+ },
10
+ test: {
11
+ environment: "jsdom",
12
+ globals: true,
13
+ include: ["components/**/*.test.tsx"],
14
+ setupFiles: ["./test/setup.ts"],
15
+ },
16
+ });