-
-
- {/* 오버레이 (패널 외부 클릭 시 닫기) */}
- {(isExpanded || isAnimating) && (
-
- )}
- {/* 확장된 설정 패널 */}
- {(isExpanded || isAnimating) && (
-
- {/* 상대방 프로필 섹션 */}
-
-
-
-
-
-
{partnerInfo?.nickname || "상대방"}
-
- {partnerInfo?.university || "예비솔커"}
-
-
-
- {isMentor ? "멘티 페이지 가기" : "멘토 페이지 가기"}
-
-
-
-
- {/* 알림 설정 */}
-
-
- {isMentor ? : }
- 알림
-
-
-
-
- {/* 참여자 섹션 */}
-
참여자 2
-
- {/* 현재 사용자 */}
-
-
- {/* '나' 표시 div */}
-
- 나
-
-
-
- {myInfo?.nickname || "나"} ({myInfo?.role === UserRole.ADMIN ? "어드민" : isMentor ? "멘토" : "멘티"})
-
-
+
-
-
-
-
- )}
-
+
+ }
+ />
+
);
};
+
export default ChatNavBar;
diff --git a/apps/web/src/app/mentor/chat/[chatId]/page.tsx b/apps/web/src/app/mentor/chat/[chatId]/page.tsx
index 4e5dc3e6e..4a5070dee 100644
--- a/apps/web/src/app/mentor/chat/[chatId]/page.tsx
+++ b/apps/web/src/app/mentor/chat/[chatId]/page.tsx
@@ -3,7 +3,6 @@ import { notFound } from "next/navigation";
import { NO_INDEX_ROBOTS } from "@/utils/seo";
import ChatContent from "./_ui/ChatContent";
-import ChatNavBar from "./_ui/ChatNavBar";
export const metadata: Metadata = {
title: "멘토와 채팅 | 솔리드커넥션",
@@ -19,11 +18,10 @@ const ChatDetailPage = async ({ params }: ChatDetailPageProps) => {
const { chatId: chatIdParam } = await params;
const chatId = Number(chatIdParam);
- if (Number.isNaN(chatId)) notFound();
+ if (!/^\d+$/.test(chatIdParam) || !Number.isSafeInteger(chatId) || chatId <= 0) notFound();
return (
-
-
+
);
};
diff --git a/apps/web/src/app/mentor/chat/_ui/ChatAccess.dom.test.tsx b/apps/web/src/app/mentor/chat/_ui/ChatAccess.dom.test.tsx
new file mode 100644
index 000000000..baa273a3f
--- /dev/null
+++ b/apps/web/src/app/mentor/chat/_ui/ChatAccess.dom.test.tsx
@@ -0,0 +1,174 @@
+// @vitest-environment jsdom
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react/pure";
+import { type ReactNode, useEffect, useState } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { getValidAccessToken } from "@/utils/axiosInstance";
+import ChatAccess from "./ChatAccess";
+
+vi.mock("@/utils/axiosInstance", () => ({ getValidAccessToken: vi.fn() }));
+
+vi.mock("@/apis/chat", async () => await import("@/apis/chat/auth"));
+vi.mock("next/link", () => ({
+ default: ({ children, href }: { children: ReactNode; href: string }) =>
{children},
+}));
+
+const onMount = vi.fn();
+const onUnmount = vi.fn();
+
+function token(userId = 7, expiration = Math.floor(Date.now() / 1000) + 3600) {
+ return `header.${btoa(JSON.stringify({ sub: userId, role: "MENTOR", exp: expiration }))}.signature`;
+}
+
+function Draft({ userId }: { userId: number }) {
+ const [value, setValue] = useState("");
+ useEffect(() => {
+ onMount();
+ return () => {
+ onUnmount();
+ };
+ }, []);
+ return
setValue(event.target.value)} />;
+}
+
+function renderDraft() {
+ return render(
{(userId) => });
+}
+
+beforeEach(() => {
+ vi.mocked(getValidAccessToken).mockReset().mockResolvedValue(null);
+ onMount.mockClear();
+ onUnmount.mockClear();
+ useAuthStore.getState().setAccessToken(token());
+});
+
+afterEach(() => {
+ useAuthStore.getState().clearAccessToken();
+});
+
+describe("ChatAccess identity continuity", () => {
+ it("refreshes an expired identity on first entry before showing the login gate", async () => {
+ useAuthStore.getState().setAccessToken(token(7, Math.floor(Date.now() / 1000) - 1));
+ let finish!: (token: string) => void;
+ vi.mocked(getValidAccessToken).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ renderDraft();
+ await waitFor(() => expect(getValidAccessToken).toHaveBeenCalledOnce());
+ expect(screen.queryByRole("link", { name: "로그인하기" })).toBeNull();
+ expect(screen.getByRole("status")).toBeTruthy();
+ await act(async () => {
+ const refreshed = token();
+ useAuthStore.getState().setRefreshedAccessToken(refreshed, useAuthStore.getState().sessionVersion);
+ finish(refreshed);
+ });
+ expect(screen.getByRole("textbox", { name: "메시지 7" })).toBeTruthy();
+ });
+
+ it("offers explicit retry after a transient refresh failure on first entry", async () => {
+ useAuthStore.getState().setAccessToken(token(7, Math.floor(Date.now() / 1000) - 1));
+ vi.mocked(getValidAccessToken).mockImplementationOnce(async () => {
+ useAuthStore.getState().setRefreshStatus("retryable");
+ throw new Error("temporary failure");
+ });
+ renderDraft();
+ const retry = await screen.findByRole("button", { name: "다시 확인" });
+ expect(getValidAccessToken).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("link", { name: "로그인하기" })).toBeNull();
+ vi.mocked(getValidAccessToken).mockImplementationOnce(async () => {
+ const refreshed = token();
+ useAuthStore.getState().setRefreshedAccessToken(refreshed, useAuthStore.getState().sessionVersion);
+ return refreshed;
+ });
+ fireEvent.click(retry);
+ await screen.findByRole("textbox", { name: "메시지 7" });
+ expect(getValidAccessToken).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not reuse a remembered identity from another authenticated session", () => {
+ renderDraft();
+ act(() => useAuthStore.getState().setAccessToken(token(8, Math.floor(Date.now() / 1000) - 1)));
+ expect(screen.queryByRole("textbox")).toBeNull();
+ expect(screen.getByRole("status")).toBeTruthy();
+ expect(getValidAccessToken).toHaveBeenCalledOnce();
+ });
+
+ it("preserves the draft after token expiry before refresh starts", () => {
+ renderDraft();
+ const input = screen.getByRole("textbox", { name: "메시지 7" }) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "만료 직전 작성한 원문" } });
+ act(() =>
+ useAuthStore.setState({
+ accessToken: token(7, Math.floor(Date.now() / 1000) - 1),
+ refreshStatus: "success",
+ }),
+ );
+ expect(screen.getByRole("textbox", { name: "메시지 7" })).toBe(input);
+ expect(input.value).toBe("만료 직전 작성한 원문");
+ expect(onUnmount).not.toHaveBeenCalled();
+ });
+
+ it("preserves the mounted composer and its draft while an expired token is refreshed", () => {
+ renderDraft();
+ const input = screen.getByRole("textbox", { name: "메시지 7" }) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "아직 보내지 않은 원문" } });
+ act(() => {
+ useAuthStore.setState({
+ accessToken: token(7, Math.floor(Date.now() / 1000) - 1),
+ refreshStatus: "refreshing",
+ isLoading: true,
+ });
+ });
+
+ expect(screen.getByRole("textbox", { name: "메시지 7" })).toBe(input);
+ expect(input.value).toBe("아직 보내지 않은 원문");
+ expect(onMount).toHaveBeenCalledOnce();
+ expect(onUnmount).not.toHaveBeenCalled();
+
+ act(() => useAuthStore.getState().setAccessToken(token()));
+ expect(screen.getByRole("textbox", { name: "메시지 7" })).toBe(input);
+ expect(input.value).toBe("아직 보내지 않은 원문");
+ expect(onMount).toHaveBeenCalledOnce();
+ });
+
+ it("unmounts the composer immediately when refresh fails and clears authentication", () => {
+ renderDraft();
+ act(() => {
+ useAuthStore.setState({
+ accessToken: token(7, Math.floor(Date.now() / 1000) - 1),
+ refreshStatus: "refreshing",
+ isLoading: true,
+ });
+ });
+ expect(screen.getByRole("textbox")).toBeTruthy();
+
+ act(() => {
+ useAuthStore.getState().clearAccessToken();
+ useAuthStore.getState().setRefreshStatus("failed");
+ });
+ expect(screen.queryByRole("textbox")).toBeNull();
+ expect(onUnmount).toHaveBeenCalledOnce();
+ expect(screen.getByRole("link", { name: "로그인하기" }).getAttribute("href")).toBe("/login");
+ });
+
+ it("does not reuse the previous identity after logout during token refresh", () => {
+ renderDraft();
+ act(() => {
+ useAuthStore.setState({
+ accessToken: token(7, Math.floor(Date.now() / 1000) - 1),
+ refreshStatus: "refreshing",
+ isLoading: true,
+ });
+ });
+ act(() => useAuthStore.getState().clearAccessToken());
+ expect(screen.queryByRole("textbox")).toBeNull();
+ expect(onUnmount).toHaveBeenCalledOnce();
+
+ act(() => useAuthStore.setState({ refreshStatus: "refreshing", isLoading: true }));
+ expect(screen.queryByRole("textbox")).toBeNull();
+ expect(screen.getByRole("status").textContent).toBe("로그인 정보를 확인하고 있어요.");
+ });
+});
diff --git a/apps/web/src/app/mentor/chat/_ui/ChatAccess.tsx b/apps/web/src/app/mentor/chat/_ui/ChatAccess.tsx
new file mode 100644
index 000000000..77f4c2144
--- /dev/null
+++ b/apps/web/src/app/mentor/chat/_ui/ChatAccess.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import Link from "next/link";
+import { type ReactNode, useEffect, useRef } from "react";
+import { useChatUserId } from "@/apis/chat";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { getValidAccessToken } from "@/utils/axiosInstance";
+
+const ChatAccess = ({
+ children,
+ fallbackHeader,
+}: {
+ children: (userId: number) => ReactNode;
+ fallbackHeader?: ReactNode;
+}) => {
+ const userId = useChatUserId();
+ const initialized = useAuthStore((state) => state.isInitialized);
+ const loading = useAuthStore((state) => state.isLoading);
+ const authenticated = useAuthStore((state) => state.isAuthenticated);
+ const refreshStatus = useAuthStore((state) => state.refreshStatus);
+ const sessionVersion = useAuthStore((state) => state.sessionVersion);
+ const lastValidatedIdentity = useRef<{ userId: number; sessionVersion: number } | null>(null);
+
+ useEffect(
+ function rememberValidatedIdentity() {
+ if (userId) lastValidatedIdentity.current = { userId, sessionVersion };
+ else if (!authenticated) lastValidatedIdentity.current = null;
+ },
+ [authenticated, sessionVersion, userId],
+ );
+
+ // Token rotation must not destroy a room's draft. A failed refresh or logout still closes it immediately.
+ const rememberedIdentity = lastValidatedIdentity.current;
+ const activeUserId =
+ userId ??
+ (authenticated && refreshStatus !== "failed" && rememberedIdentity?.sessionVersion === sessionVersion
+ ? rememberedIdentity.userId
+ : null);
+
+ useEffect(
+ function refreshIdentityOnChatEntry() {
+ if (!initialized || !authenticated || activeUserId || useAuthStore.getState().sessionVersion !== sessionVersion)
+ return;
+ void getValidAccessToken().catch(() => {});
+ },
+ [initialized, authenticated, activeUserId, sessionVersion],
+ );
+
+ if (activeUserId) return children(activeUserId);
+ if (initialized && authenticated && !loading && refreshStatus === "retryable") {
+ return (
+ <>
+ {fallbackHeader}
+
+
+ 로그인 정보를 확인하지 못했어요. 다시 시도해주세요.
+
+
+
+ >
+ );
+ }
+ if (!initialized || loading || authenticated) {
+ return (
+ <>
+ {fallbackHeader}
+
+ 로그인 정보를 확인하고 있어요.
+
+ >
+ );
+ }
+ if (!activeUserId) {
+ return (
+ <>
+ {fallbackHeader}
+
+
채팅하려면 로그인이 필요해요.
+
+ 로그인하기
+
+
+ >
+ );
+ }
+ return null;
+};
+
+export default ChatAccess;
diff --git a/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.dom.test.tsx b/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.dom.test.tsx
new file mode 100644
index 000000000..ee4a5f4a2
--- /dev/null
+++ b/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.dom.test.tsx
@@ -0,0 +1,125 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen, within } from "@testing-library/react/pure";
+import type { ComponentProps, ReactNode } from "react";
+import { beforeEach, expect, it, vi } from "vitest";
+import { type ChatRoom, useGetChatRooms } from "@/apis/chat";
+import type { UserRole } from "@/types/mentor";
+import ChatPageClient from ".";
+
+const mocks = vi.hoisted(() => ({
+ userId: 1 as number | null,
+ role: "MENTEE" as UserRole,
+ query: {
+ data: undefined as ChatRoom[] | undefined,
+ isPending: true,
+ isError: false,
+ isFetching: false,
+ refetch: vi.fn(),
+ },
+}));
+
+vi.mock("@/apis/chat", () => ({ useGetChatRooms: vi.fn(() => mocks.query) }));
+vi.mock("@/lib/zustand/useAuthStore", () => ({
+ default: (selector: (state: { clientRole: UserRole; serverRole: UserRole }) => unknown) =>
+ selector({ clientRole: mocks.role, serverRole: mocks.role }),
+}));
+vi.mock("../ChatAccess", () => ({
+ default: ({ children }: { children: (userId: number) => ReactNode }) =>
+ mocks.userId === null ?
로그인이 필요해요.
: children(mocks.userId),
+}));
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...props }: ComponentProps<"a"> & { href: string }) => (
+
+ {children}
+
+ ),
+}));
+vi.mock("@/components/ui/ProfileWithBadge", () => ({ default: () =>
}));
+vi.mock("@/public/svgs/mentor", () => ({ IconSearchBlue: () =>
, IconSolidConnentionLogo: () =>
}));
+
+beforeEach(() => {
+ mocks.userId = 1;
+ mocks.role = "MENTEE" as UserRole;
+ Object.assign(mocks.query, { data: undefined, isPending: true, isError: false, isFetching: false });
+ mocks.query.refetch.mockReset();
+ vi.mocked(useGetChatRooms).mockClear();
+});
+
+const room = (id: number, lastReceivedTime: string): ChatRoom => ({
+ id,
+ lastReceivedTime,
+ lastChatMessage: `메시지 ${id}`,
+ unReadCount: id === 2 ? 12 : 0,
+ partner: { partnerId: id + 100, nickname: `상대방 ${id}`, profileUrl: null, university: null },
+});
+
+it("separates loading, initial failure and successful role-specific empty states", () => {
+ const { rerender } = render(
);
+ expect(screen.getByRole("status").textContent).toContain("불러오고 있어요");
+ expect(screen.queryByText(/현재 매칭된 멘토가 없어요/)).toBeNull();
+
+ Object.assign(mocks.query, { isPending: false, isError: true });
+ rerender(
);
+ expect(screen.getByRole("alert").textContent).toContain("불러오지 못했어요");
+ fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
+ expect(mocks.query.refetch).toHaveBeenCalledTimes(1);
+
+ Object.assign(mocks.query, { data: [], isError: false });
+ rerender(
);
+ expect(screen.getByText(/현재 매칭된 멘토가 없어요/)).toBeTruthy();
+ expect(screen.getByRole("link", { name: "멘토 찾으러 가볼까요?" }).getAttribute("href")).toBe("/mentor");
+
+ mocks.role = "MENTOR" as UserRole;
+ rerender(
);
+ expect(screen.getByText("현재 진행 중인 채팅이 없어요.")).toBeTruthy();
+ expect(screen.queryByRole("link", { name: "멘토 찾으러 가볼까요?" })).toBeNull();
+});
+
+it("sorts a clone by recent valid dates and retains room links and unread counts", () => {
+ const rooms = [room(1, "2026-10-25T00:00:00Z"), room(3, "invalid-date"), room(2, "2026-10-27T00:00:00Z")];
+ Object.freeze(rooms);
+ Object.assign(mocks.query, { data: rooms, isPending: false });
+ render(
);
+ const list = screen.getByRole("list", { name: "채팅방" });
+ const links = within(list).getAllByRole("link");
+ expect(links.map((link) => link.getAttribute("href"))).toEqual([
+ "/mentor/chat/2",
+ "/mentor/chat/1",
+ "/mentor/chat/3",
+ ]);
+ expect(rooms.map((item) => item.id)).toEqual([1, 3, 2]);
+ expect(list.querySelectorAll("time")).toHaveLength(2);
+ expect(screen.getByText("안 읽은 메시지 12개")).toBeTruthy();
+ expect(screen.getByText("9+")).toBeTruthy();
+});
+
+it("keeps the previous list during refresh and reports a failed refresh with retry", () => {
+ Object.assign(mocks.query, { data: [room(1, "2026-10-27T00:00:00Z")], isPending: false, isFetching: true });
+ const { rerender } = render(
);
+ expect(screen.getByRole("status").textContent).toContain("업데이트");
+ expect(screen.getByRole("list", { name: "채팅방" })).toBeTruthy();
+
+ Object.assign(mocks.query, { isError: true, isFetching: false });
+ rerender(
);
+ expect(screen.getByRole("alert").textContent).toContain("이전 목록을 표시하고 있어요");
+ expect(
+ within(screen.getByRole("list", { name: "채팅방" }))
+ .getByRole("link")
+ .getAttribute("href"),
+ ).toBe("/mentor/chat/1");
+ fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
+ expect(mocks.query.refetch).toHaveBeenCalledTimes(1);
+});
+
+it("does not mount the private room query before ChatAccess authorizes a user", () => {
+ mocks.userId = null;
+ const { rerender } = render(
);
+ expect(screen.getByText("로그인이 필요해요.")).toBeTruthy();
+ expect(useGetChatRooms).not.toHaveBeenCalled();
+
+ mocks.userId = 2;
+ Object.assign(mocks.query, { data: [room(2, "2026-10-27T00:00:00Z")], isPending: false });
+ rerender(
);
+ expect(screen.getByRole("list", { name: "채팅방" })).toBeTruthy();
+ expect(useGetChatRooms).toHaveBeenCalledExactlyOnceWith(2);
+});
diff --git a/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.tsx b/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.tsx
index cf67d213f..a8a434d73 100644
--- a/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.tsx
+++ b/apps/web/src/app/mentor/chat/_ui/ChatPageClient/index.tsx
@@ -1,117 +1,183 @@
"use client";
import Link from "next/link";
-import { useGetChatRooms } from "@/apis/chat";
-import { useGetMyInfo } from "@/apis/MyPage";
+import { type ChatRoom, useGetChatRooms } from "@/apis/chat";
import ProfileWithBadge from "@/components/ui/ProfileWithBadge";
+import useAuthStore from "@/lib/zustand/useAuthStore";
import { IconSearchBlue, IconSolidConnentionLogo } from "@/public/svgs/mentor";
import { UserRole } from "@/types/mentor";
import { convertISODateToKoreanTime } from "@/utils/datetimeUtils";
+import ChatAccess from "../ChatAccess";
-const ChatPageClient = () => {
- const { data: chatRooms = [] } = useGetChatRooms();
- const { data: myInfo } = useGetMyInfo();
+const getReceivedTimestamp = (value: string) => {
+ if (typeof value !== "string" || !value.trim()) return null;
+ const timestamp = new Date(value).getTime();
+ return Number.isFinite(timestamp) ? timestamp : null;
+};
- const isMentee = myInfo?.role === UserRole.MENTEE;
- const isPartnerMentor = isMentee;
+const compareRecentRooms = (first: ChatRoom, second: ChatRoom) => {
+ const firstTime = getReceivedTimestamp(first.lastReceivedTime);
+ const secondTime = getReceivedTimestamp(second.lastReceivedTime);
+ if (firstTime === null) return secondTime === null ? 0 : 1;
+ if (secondTime === null) return -1;
+ return secondTime - firstTime;
+};
- // 연결된 멘토가 없을 때의 처리 (멘티만)
- if (chatRooms.length === 0 && isMentee) {
- return (
-
-
-
-
-
+const EmptyChatRooms = ({ isMentee }: { isMentee: boolean }) => (
+
+
+
+
+
+ {isMentee ? (
+ <>
현재 매칭된 멘토가 없어요.
멘토부터 찾아볼까요?
-
-
+ ) : (
+ "현재 진행 중인 채팅이 없어요."
+ )}
+
+ {isMentee && (
+
+ 멘토 찾으러 가볼까요?
+
+ )}
+
+);
+
+const ChatRoomList = ({ userId }: { userId: number }) => {
+ const { data: chatRooms, isPending, isError, isFetching, refetch } = useGetChatRooms(userId);
+ const role = useAuthStore((state) => state.clientRole ?? state.serverRole);
+ const isMentee = role === UserRole.MENTEE;
+
+ if (!chatRooms) {
+ return isError ? (
+
+
채팅 목록을 불러오지 못했어요.
+
+ ) : (
+
+ {isPending ? "채팅 목록을 불러오고 있어요." : "채팅 목록을 확인하고 있어요."}
+
);
}
- // 멘토/어드민이고 채팅방이 없을 때
- if (chatRooms.length === 0) {
- return (
-
-
-
-
-
현재 진행 중인 채팅이 없어요.
-
- );
- }
+ const sortedRooms = [...chatRooms].sort(compareRecentRooms);
return (
-
- {/* 헤더 - 멘티일 때만 표시 */}
- {isMentee && (
-
-
-
-
-
-
- 멘토를 찾으러 가볼까요?
-
-
나의 멘토 찾으러가기
-
-
- ›
-
-
- )}
-
- {/* 채팅 리스트 섹션 */}
-
-
-
{isMentee ? "나의 멘토" : "나의 멘티"}
-
- {chatRooms.length}
-
+
+ {isError && (
+
+
새 채팅 목록을 불러오지 못했어요. 이전 목록을 표시하고 있어요.
+
-
- {/* 채팅 리스트 */}
-
- {chatRooms.map((chat) => {
- const { nickname, profileUrl } = chat.partner;
- const { lastChatMessage, lastReceivedTime, unReadCount } = chat;
-
- return (
-
+ )}
+ {isFetching && (
+
+ 채팅 목록을 업데이트하고 있어요.
+
+ )}
+ {sortedRooms.length === 0 ? (
+
+ ) : (
+ <>
+ {isMentee && (
+
+
-
-
-
{nickname}
-
{lastChatMessage}
-
+
+
+
+
멘토를 찾으러 가볼까요?
-
-
{convertISODateToKoreanTime(lastReceivedTime)}
- {unReadCount > 0 && (
-
- {unReadCount > 9 ? "9+" : unReadCount}
-
- )}
-
-
- );
- })}
-
-
+
나의 멘토 찾으러가기
+
+
+ ›
+
+
+ )}
+
+
+
{isMentee ? "나의 멘토" : "나의 멘티"}
+
+ {sortedRooms.length}
+ 채팅방 {sortedRooms.length}개
+
+
+
+
+ >
+ )}
);
};
+const ChatPageClient = () =>
{(userId) => };
+
export default ChatPageClient;
diff --git a/apps/web/src/app/mentor/layout.tsx b/apps/web/src/app/mentor/layout.tsx
index 7e730a49f..389a90dfa 100644
--- a/apps/web/src/app/mentor/layout.tsx
+++ b/apps/web/src/app/mentor/layout.tsx
@@ -6,6 +6,7 @@ interface MentorLayoutProps {
const CHAT_NAVIGATION_TITLES = {
"/mentor/chat": "멘토 채팅",
+ "/mentor/chat/": "",
"/mentor": "멘토",
"/mentor/modify": "멘토 페이지",
"/mentor/waiting": "멘토 대기",
diff --git a/apps/web/src/app/university/score/ScoreCard.stories.tsx b/apps/web/src/app/university/score/ScoreCard.stories.tsx
new file mode 100644
index 000000000..220b20b60
--- /dev/null
+++ b/apps/web/src/app/university/score/ScoreCard.stories.tsx
@@ -0,0 +1,15 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { ScoreSubmitStatus } from "@/types/score";
+import ScoreCard from "./ScoreCard";
+
+const meta = {
+ title: "Scores/ScoreCard",
+ component: ScoreCard,
+ args: { name: "TOEFL iBT", score: "100", date: "2026-10-01T00:00:00Z", status: ScoreSubmitStatus.APPROVED },
+} satisfies Meta
;
+export default meta;
+type Story = StoryObj;
+export const Approved: Story = { args: { isFocused: true } };
+export const Pending: Story = { args: { status: ScoreSubmitStatus.PENDING } };
+export const Rejected: Story = { args: { status: ScoreSubmitStatus.REJECTED } };
+export const Gpa: Story = { args: { name: "인하대학교", score: "4.00/4.5" } };
diff --git a/apps/web/src/components/button/BlockBtn.stories.tsx b/apps/web/src/components/button/BlockBtn.stories.tsx
new file mode 100644
index 000000000..581fc0fc2
--- /dev/null
+++ b/apps/web/src/components/button/BlockBtn.stories.tsx
@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { expect, fn, userEvent, within } from "storybook/test";
+import BlockBtn from "./BlockBtn";
+
+const meta = {
+ title: "Common/BlockBtn",
+ component: BlockBtn,
+ args: { children: "다음으로", onClick: fn() },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Primary: Story = {
+ play: async ({ canvasElement, args }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "다음으로" }));
+ await expect(args.onClick).toHaveBeenCalledTimes(1);
+ },
+};
+export const Secondary: Story = { args: { variant: "secondary", children: "저장하기" } };
+export const Disabled: Story = {
+ args: { disabled: true },
+ play: async ({ canvasElement, args }) => {
+ const button = within(canvasElement).getByRole("button");
+ await expect(button).toBeDisabled();
+ await userEvent.click(button);
+ await expect(args.onClick).not.toHaveBeenCalled();
+ },
+};
diff --git a/apps/web/src/components/layout/ReissueProvider/index.tsx b/apps/web/src/components/layout/ReissueProvider/index.tsx
index a6c18b823..59439a54a 100644
--- a/apps/web/src/components/layout/ReissueProvider/index.tsx
+++ b/apps/web/src/components/layout/ReissueProvider/index.tsx
@@ -2,63 +2,24 @@
import type React from "react";
import { useEffect } from "react";
-
-import { postReissueToken } from "@/apis/Auth";
import useAuthStore from "@/lib/zustand/useAuthStore";
+import { getValidAccessToken } from "@/utils/axiosInstance";
import { isTokenExpired } from "@/utils/jwtUtils";
interface ReissueProviderProps {
children: React.ReactNode;
}
-const ReissueProvider: React.FC = ({ children }) => {
- useEffect(() => {
- let isMounted = true;
-
- const initializeAuth = async () => {
- const { accessToken, setInitialized, setLoading, setRefreshStatus, shouldAttemptRefresh } =
- useAuthStore.getState();
-
- if (accessToken && !isTokenExpired(accessToken)) {
- setInitialized(true);
- return;
- }
-
- if (!shouldAttemptRefresh) {
- setInitialized(true);
- return;
- }
-
- try {
- setRefreshStatus("refreshing");
- setLoading(true);
-
- // HttpOnly refresh token 쿠키가 있으면 access token을 복구합니다.
- await postReissueToken();
- if (isMounted) {
- useAuthStore.getState().setRefreshStatus("success");
- }
- } catch {
- if (isMounted) {
- useAuthStore.getState().clearAccessToken();
- useAuthStore.getState().setRefreshStatus("failed");
- }
- } finally {
- if (isMounted) {
- useAuthStore.getState().setLoading(false);
- useAuthStore.getState().setInitialized(true);
- }
- }
- };
-
- initializeAuth();
-
- // 컴포넌트 언마운트 시 플래그 설정
- return () => {
- isMounted = false;
- };
+const ReissueProvider = ({ children }: ReissueProviderProps) => {
+ useEffect(function initializeAuthentication() {
+ const auth = useAuthStore.getState();
+ if ((auth.accessToken && !isTokenExpired(auth.accessToken)) || !auth.shouldAttemptRefresh) {
+ auth.setInitialized(true);
+ return;
+ }
+ // The coordinator owns status and session checks for every refresh caller.
+ void getValidAccessToken().catch(() => {});
}, []);
-
return <>{children}>;
};
diff --git a/apps/web/src/components/ui/EmptySdwBCards.stories.tsx b/apps/web/src/components/ui/EmptySdwBCards.stories.tsx
new file mode 100644
index 000000000..181e1cc17
--- /dev/null
+++ b/apps/web/src/components/ui/EmptySdwBCards.stories.tsx
@@ -0,0 +1,8 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import EmptySdwBCards from "./EmptySdwBCards";
+
+const meta = { title: "Common/EmptyCards", component: EmptySdwBCards } satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const SearchEmpty: Story = { args: { message: "검색 결과가 없어요. 다른 검색어를 입력해주세요." } };
diff --git a/apps/web/src/components/ui/Inputa.stories.tsx b/apps/web/src/components/ui/Inputa.stories.tsx
new file mode 100644
index 000000000..c30271d2e
--- /dev/null
+++ b/apps/web/src/components/ui/Inputa.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { expect, userEvent, within } from "storybook/test";
+import { Input } from "./Inputa";
+
+const meta = {
+ title: "Common/Input",
+ component: Input,
+ args: { "aria-label": "닉네임", placeholder: "닉네임을 입력하세요" },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const input = within(canvasElement).getByRole("textbox", { name: "닉네임" });
+ await userEvent.type(input, "예비 교환학생");
+ await expect(input).toHaveValue("예비 교환학생");
+ },
+};
+export const Gray: Story = { args: { variant: "gray" } };
+export const Disabled: Story = { args: { disabled: true, value: "수정할 수 없는 닉네임" } };
diff --git a/apps/web/src/components/ui/ProfileWithBadge.stories.tsx b/apps/web/src/components/ui/ProfileWithBadge.stories.tsx
new file mode 100644
index 000000000..64142aeab
--- /dev/null
+++ b/apps/web/src/components/ui/ProfileWithBadge.stories.tsx
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { expect, waitFor, within } from "storybook/test";
+import ProfileWithBadge from "./ProfileWithBadge";
+
+const meta = { title: "Common/Profile", component: ProfileWithBadge, args: { profileImageUrl: null } } satisfies Meta<
+ typeof ProfileWithBadge
+>;
+export default meta;
+type Story = StoryObj;
+export const Mentee: Story = {
+ play: async ({ canvasElement }) => {
+ const image = within(canvasElement).getByRole("img", { name: "프로필 이미지" }) as HTMLImageElement;
+ await waitFor(() => expect(image.naturalWidth).toBeGreaterThan(0));
+ },
+};
+export const Mentor: Story = { args: { isMentor: true } };
+export const ChatSize: Story = { args: { isMentor: true, width: 32, height: 32 } };
diff --git a/apps/web/src/components/ui/Tab/Tab.stories.tsx b/apps/web/src/components/ui/Tab/Tab.stories.tsx
new file mode 100644
index 000000000..4a0c71a49
--- /dev/null
+++ b/apps/web/src/components/ui/Tab/Tab.stories.tsx
@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from "@storybook/nextjs-vite";
+import { useState } from "react";
+import { expect, fn, userEvent, within } from "storybook/test";
+import Tab from "./index";
+
+const meta = {
+ title: "Common/Tab",
+ component: Tab,
+ args: { choices: ["공인어학", "학점"], choice: "공인어학", setChoice: fn() },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Interactive: Story = {
+ render: function InteractiveTab(args) {
+ const [choice, setChoice] = useState(args.choice);
+ return (
+ <>
+
+ 선택한 탭: {choice}
+ >
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "학점" }));
+ await expect(canvas.getByRole("status")).toHaveTextContent("선택한 탭: 학점");
+ },
+};
diff --git a/apps/web/src/lib/chat/ChatSession.test.ts b/apps/web/src/lib/chat/ChatSession.test.ts
new file mode 100644
index 000000000..a4c21bda7
--- /dev/null
+++ b/apps/web/src/lib/chat/ChatSession.test.ts
@@ -0,0 +1,490 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ChatHistoriesResponse } from "@/apis/chat";
+import type { ChatMessage } from "@/types/chat";
+import { ChatSession } from "./ChatSession";
+import type { ChatTransportEvents, OutgoingMessage } from "./types";
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+const message = (id: number, content = `메시지 ${id}`, senderId = 7): ChatMessage => ({
+ id,
+ content,
+ senderId,
+ createdAt: new Date(Date.UTC(2026, 9, 27, 0, 0, Math.abs(id))).toISOString(),
+ attachments: [],
+});
+
+const history = (content: ChatMessage[], nextPageNumber = -1): ChatHistoriesResponse => ({ content, nextPageNumber });
+
+async function settle() {
+ for (let turn = 0; turn < 20; turn += 1) await Promise.resolve();
+}
+
+const sessions: ChatSession[] = [];
+
+function createSession(initialOutgoing: OutgoingMessage[] = []) {
+ const events: ChatTransportEvents[] = [];
+ const transports: {
+ start: ReturnType;
+ stop: ReturnType;
+ reconnect: ReturnType;
+ publish: ReturnType;
+ }[] = [];
+ const loadHistory = vi
+ .fn<(page: number, signal: AbortSignal) => Promise>()
+ .mockResolvedValue(history([]));
+ const markRead = vi.fn<(signal: AbortSignal) => Promise>().mockResolvedValue(undefined);
+ const saveOutgoing = vi.fn<(outgoing: OutgoingMessage[]) => void>();
+ const onMessagesChanged = vi.fn();
+ const onRead = vi.fn();
+ const session = new ChatSession({
+ userId: 7,
+ loadHistory,
+ markRead,
+ saveOutgoing,
+ onMessagesChanged,
+ onRead,
+ initialOutgoing,
+ createTransport: (callbacks) => {
+ const transport = { start: vi.fn(), stop: vi.fn(), reconnect: vi.fn(), publish: vi.fn() };
+ events.push(callbacks);
+ transports.push(transport);
+ return transport;
+ },
+ });
+ sessions.push(session);
+ return { session, events, transports, loadHistory, markRead, saveOutgoing, onMessagesChanged, onRead };
+}
+
+afterEach(() => {
+ for (const session of sessions.splice(0)) session.stop();
+});
+
+describe("ChatSession history and connection lifecycle", () => {
+ it("keeps a live message received before the initial HTTP snapshot resolves", async () => {
+ const pending = deferred();
+ const { session, events, loadHistory } = createSession();
+ loadHistory.mockReturnValueOnce(pending.promise);
+ session.start();
+ events[0].onMessage(message(2));
+ pending.resolve(history([message(1)]));
+ await settle();
+
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([1, 2]);
+ expect(session.getSnapshot().historyStatus).toBe("ready");
+ });
+
+ it("recovers the older-history cursor after an initial HTTP failure with live messages present", async () => {
+ const pending = deferred();
+ const { session, events, loadHistory } = createSession();
+ loadHistory.mockReturnValueOnce(pending.promise);
+ session.start();
+ events[0].onMessage(message(3));
+ pending.reject(new Error("temporary network failure"));
+ await settle();
+ expect(session.getSnapshot().syncError).not.toBeNull();
+ loadHistory.mockResolvedValueOnce(history([message(3), message(2)], 2));
+
+ await session.syncLatest();
+ expect(session.getSnapshot().hasMore).toBe(true);
+ loadHistory.mockResolvedValueOnce(history([message(1)], -1));
+ await session.loadOlder();
+ expect(loadHistory.mock.calls.at(-1)?.[0]).toBe(2);
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([1, 2, 3]);
+ });
+
+ it("follows reconnect pages until an already observed server message closes the gap", async () => {
+ const { session, events, loadHistory } = createSession();
+ loadHistory.mockResolvedValueOnce(history([message(10), message(9)], 2));
+ session.start();
+ await settle();
+ loadHistory.mockClear();
+ loadHistory.mockImplementation(async (page) => {
+ const pages = new Map([
+ [1, history([message(16), message(15)], 2)],
+ [2, history([message(14), message(13)], 3)],
+ [3, history([message(12), message(11)], 4)],
+ [4, history([message(10), message(9)], 5)],
+ ]);
+ const result = pages.get(page);
+ if (!result) throw new Error(`Unexpected page ${page}`);
+ return result;
+ });
+
+ events[0].onConnected();
+ await settle();
+
+ expect(loadHistory.mock.calls.map(([page]) => page)).toEqual([1, 2, 3, 4]);
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([9, 10, 11, 12, 13, 14, 15, 16]);
+ });
+
+ it("recovers every reconnect page after the room was initially empty", async () => {
+ const { session, loadHistory } = createSession();
+ session.start();
+ await settle();
+ expect(session.getSnapshot().messages).toEqual([]);
+ expect(session.getSnapshot().hasMore).toBe(false);
+ loadHistory.mockClear();
+ const allMessages = Array.from({ length: 45 }, (_, index) => message(45 - index));
+ loadHistory.mockImplementation(async (page) => {
+ const offset = (page - 1) * 20;
+ return history(allMessages.slice(offset, offset + 20), page < 3 ? page + 1 : -1);
+ });
+
+ await session.syncLatest();
+
+ expect(loadHistory.mock.calls.map(([page]) => page)).toEqual([1, 2, 3]);
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual(
+ Array.from({ length: 45 }, (_, index) => index + 1),
+ );
+ expect(session.getSnapshot().hasMore).toBe(false);
+ });
+
+ it("continues older history after catch-up pages even if an earlier older request finishes late", async () => {
+ const { session, loadHistory } = createSession();
+ loadHistory.mockResolvedValueOnce(history([message(10), message(9)], 2));
+ session.start();
+ await settle();
+ const pendingOlder = deferred();
+ loadHistory.mockReturnValueOnce(pendingOlder.promise);
+ const loadingOlder = session.loadOlder();
+ loadHistory.mockImplementation(async (page) => {
+ const pages = new Map([
+ [1, history([message(16), message(15)], 2)],
+ [2, history([message(14), message(13)], 3)],
+ [3, history([message(12), message(11)], 4)],
+ [4, history([message(10), message(9)], 5)],
+ [5, history([message(8), message(7)], 6)],
+ [6, history([message(6), message(5)], 7)],
+ ]);
+ const result = pages.get(page);
+ if (!result) throw new Error(`Unexpected page ${page}`);
+ return result;
+ });
+
+ await session.syncLatest();
+ pendingOlder.resolve(history([message(8), message(7)], 3));
+ await loadingOlder;
+ loadHistory.mockClear();
+ await session.loadOlder();
+
+ expect(loadHistory.mock.calls.map(([page]) => page)).toEqual([5, 6]);
+ expect(session.getSnapshot().hasMore).toBe(true);
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
+ });
+
+ it("keeps older history exhausted after reconnect when all old messages were already loaded", async () => {
+ const { session, loadHistory } = createSession();
+ loadHistory
+ .mockResolvedValueOnce(history([message(4), message(3)], 2))
+ .mockResolvedValueOnce(history([message(2), message(1)], -1));
+ session.start();
+ await settle();
+ await session.loadOlder();
+ expect(session.getSnapshot().hasMore).toBe(false);
+ loadHistory.mockImplementation(async (page) => {
+ if (page === 1) return history([message(8), message(7)], 2);
+ if (page === 2) return history([message(6), message(5)], 3);
+ return history([message(4), message(3)], 4);
+ });
+
+ await session.syncLatest();
+ expect(session.getSnapshot().hasMore).toBe(false);
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ loadHistory.mockClear();
+ await session.loadOlder();
+ expect(loadHistory).not.toHaveBeenCalled();
+ });
+
+ it("closes the pre-subscription gap even when a newer live frame is already in the snapshot", async () => {
+ const initialRequest = deferred();
+ const { session, events, loadHistory } = createSession();
+ loadHistory.mockReturnValueOnce(initialRequest.promise);
+ loadHistory.mockImplementation(async (page) => {
+ if (page === 1) return history([message(6), message(5)], 2);
+ if (page === 2) return history([message(4), message(3)], 3);
+ return history([message(2), message(1)], -1);
+ });
+ session.start();
+ events[0].onConnected();
+ events[0].onMessage(message(6));
+ initialRequest.resolve(history([message(2), message(1)], 2));
+ await settle();
+
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(loadHistory.mock.calls.map(([page]) => page)).toEqual([1, 1, 2, 3]);
+ });
+
+ it("ignores an aborted initial request and the old socket after start-stop-start", async () => {
+ const oldRequest = deferred();
+ const { session, events, loadHistory, transports } = createSession();
+ loadHistory.mockReturnValueOnce(oldRequest.promise).mockResolvedValueOnce(history([message(2)]));
+ session.start();
+ const oldSignal = loadHistory.mock.calls[0][1];
+ session.stop();
+ session.start();
+ await settle();
+
+ oldRequest.resolve(history([message(1)]));
+ events[0].onMessage(message(3));
+ events[0].onConnection("error");
+ events[1].onConnection("connected");
+ await settle();
+
+ expect(oldSignal.aborted).toBe(true);
+ expect(transports[0].stop).toHaveBeenCalledOnce();
+ expect(transports[1].start).toHaveBeenCalledOnce();
+ expect(session.getSnapshot().messages.map((item) => item.id)).toEqual([2]);
+ expect(session.getSnapshot().connection).toBe("connected");
+ });
+});
+
+describe("ChatSession outgoing messages", () => {
+ it("keeps an unconfirmed restored send when a later identical send is echoed after history", async () => {
+ const original = createSession();
+ original.session.start();
+ await settle();
+ original.events[0].onConnection("connected");
+ original.session.publish({ content: "같은 내용", senderId: 7 });
+ const saved = original.session.getSnapshot().outgoing;
+ original.session.stop();
+
+ const { session, events, loadHistory } = createSession(saved);
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.publish({ content: "같은 내용", senderId: 7 });
+ loadHistory.mockResolvedValue(history([message(1, "같은 내용")]));
+ await session.syncLatest();
+ events[0].onMessage(message(1, "같은 내용"));
+ expect(session.getSnapshot().outgoing).toEqual([{ ...saved[0], status: "unconfirmed" }]);
+ events[0].onMessage(message(1, "같은 내용"));
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+ });
+
+ it("confirms the first socket echo even when history already contains the delivery", async () => {
+ const { session, events, loadHistory } = createSession();
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.publish({ content: "먼저 기록에 도착", senderId: 7 });
+ loadHistory.mockResolvedValue(history([message(1, "먼저 기록에 도착")]));
+ await session.syncLatest();
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+ session.publish({ content: "먼저 기록에 도착", senderId: 7 });
+
+ events[0].onMessage(message(1, "먼저 기록에 도착"));
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+ events[0].onMessage(message(1, "먼저 기록에 도착"));
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+ events[0].onMessage(message(2, "먼저 기록에 도착"));
+ expect(session.getSnapshot().outgoing).toEqual([]);
+ });
+
+ it("does not confirm a new send with an echo already present before publish", async () => {
+ const { session, events, loadHistory } = createSession();
+ loadHistory.mockResolvedValue(history([message(1, "같은 내용")]));
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.publish({ content: "같은 내용", senderId: 7 });
+ events[0].onMessage(message(1, "같은 내용"));
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+ events[0].onMessage(message(2, "같은 내용"));
+ expect(session.getSnapshot().outgoing).toEqual([]);
+ });
+
+ it("consumes one identical outgoing message per new self echo and ignores duplicate frames", async () => {
+ const { session, events } = createSession();
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ expect(session.publish({ content: "같은 내용", senderId: 7 })).toBe(true);
+ expect(session.publish({ content: "같은 내용", senderId: 7 })).toBe(true);
+ const secondClientId = session.getSnapshot().outgoing[1].clientId;
+
+ events[0].onMessage(message(1, "같은 내용"));
+ expect(session.getSnapshot().outgoing.map((item) => item.clientId)).toEqual([secondClientId]);
+ events[0].onMessage(message(1, "같은 내용"));
+ expect(session.getSnapshot().outgoing.map((item) => item.clientId)).toEqual([secondClientId]);
+ events[0].onMessage(message(2, "같은 내용", 9));
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+ events[0].onMessage(message(3, "같은 내용"));
+ expect(session.getSnapshot().outgoing).toEqual([]);
+ });
+
+ it("keeps sends unconfirmed when an echo has no server ID and never automatically republishes", async () => {
+ vi.useFakeTimers();
+ const { session, events, transports } = createSession();
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.publish({ content: "확인 필요", senderId: 7 });
+ events[0].onMessage(message(-1, "확인 필요"));
+ expect(session.getSnapshot().outgoing).toHaveLength(1);
+
+ await vi.advanceTimersByTimeAsync(15000);
+ expect(session.getSnapshot().outgoing[0].status).toBe("unconfirmed");
+ expect(transports[0].publish).toHaveBeenCalledOnce();
+ });
+
+ it("returns false on publish failure and removes only that attempted outgoing item", async () => {
+ const { session, events, transports } = createSession();
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.publish({ content: "먼저 전송", senderId: 7 });
+ const previous = session.getSnapshot().outgoing;
+ transports[0].publish.mockImplementationOnce(() => {
+ throw new Error("socket closed");
+ });
+
+ expect(session.publish({ content: "입력 유지", senderId: 7 })).toBe(false);
+ expect(session.getSnapshot().outgoing).toEqual(previous);
+ });
+
+ it("restores a saved outgoing item as unconfirmed and blocks late uploads after stop", async () => {
+ const { session, events, saveOutgoing, transports } = createSession();
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.publish({ content: "보관할 원문", senderId: 7 });
+ const upload = deferred();
+ const uploadFn = vi.fn().mockReturnValue(upload.promise);
+ const sending = session.sendImages([new File(["image"], "photo.png", { type: "image/png" })], uploadFn);
+ session.stop();
+ upload.resolve(["https://cdn.example.com/photo.png"]);
+
+ await expect(sending).resolves.toBe(false);
+ expect(transports[0].publish).toHaveBeenCalledOnce();
+ expect(uploadFn.mock.calls[0][1].aborted).toBe(true);
+ const saved = saveOutgoing.mock.calls.at(-1)?.[0] ?? [];
+ const restored = createSession(saved).session.getSnapshot().outgoing;
+ expect(restored).toHaveLength(1);
+ expect(restored[0]).toMatchObject({ content: "보관할 원문", status: "unconfirmed" });
+ });
+});
+
+describe("ChatSession read batching", () => {
+ it("coalesces messages, pauses while hidden and follows messages received during a read", async () => {
+ vi.useFakeTimers();
+ const pendingRead = deferred();
+ const { session, events, markRead, onRead } = createSession();
+ markRead.mockReturnValueOnce(pendingRead.promise);
+ session.start();
+ await settle();
+ events[0].onMessage(message(1));
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(markRead).not.toHaveBeenCalled();
+
+ session.setReading(true);
+ events[0].onMessage(message(2));
+ events[0].onMessage(message(3));
+ await vi.advanceTimersByTimeAsync(300);
+ expect(markRead).toHaveBeenCalledOnce();
+ events[0].onMessage(message(4));
+ pendingRead.resolve();
+ await settle();
+ await vi.advanceTimersByTimeAsync(300);
+ expect(markRead).toHaveBeenCalledTimes(2);
+ expect(onRead).toHaveBeenCalledTimes(2);
+
+ session.setReading(false);
+ events[0].onMessage(message(5));
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(markRead).toHaveBeenCalledTimes(2);
+ session.setReading(true);
+ await vi.advanceTimersByTimeAsync(300);
+ expect(markRead).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not notify or mutate the snapshot when a stopped read finishes late", async () => {
+ vi.useFakeTimers();
+ const pendingRead = deferred();
+ const { session, markRead, onRead } = createSession();
+ markRead.mockReturnValueOnce(pendingRead.promise);
+ session.start();
+ await settle();
+ session.setReading(true);
+ await vi.advanceTimersByTimeAsync(300);
+ const signal = markRead.mock.calls[0][0];
+ session.stop();
+ const stoppedSnapshot = session.getSnapshot();
+ pendingRead.resolve();
+ await settle();
+
+ expect(signal.aborted).toBe(true);
+ expect(onRead).not.toHaveBeenCalled();
+ expect(session.getSnapshot()).toBe(stoppedSnapshot);
+ });
+});
+
+describe("ChatSession access revocation", () => {
+ it.each([403, 404])("stops and erases the timeline when older history is rejected with %s", async (status) => {
+ const { session, events, loadHistory, transports } = createSession();
+ loadHistory.mockResolvedValueOnce(history([message(1)], 2));
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ const signal = loadHistory.mock.calls[0][1];
+ loadHistory.mockRejectedValueOnce({ response: { status } });
+
+ await session.loadOlder();
+ events[0].onMessage(message(2));
+ expect(session.getSnapshot()).toMatchObject({
+ accessDenied: true,
+ messages: [],
+ connection: "error",
+ historyStatus: "error",
+ hasMore: false,
+ });
+ expect(signal.aborted).toBe(true);
+ expect(transports[0].stop).toHaveBeenCalledOnce();
+ expect(session.publish({ content: "차단할 전송", senderId: 7 })).toBe(false);
+ });
+
+ it.each([403, 404])("stops and erases the timeline when a read is rejected with %s", async (status) => {
+ vi.useFakeTimers();
+ const { session, events, loadHistory, markRead, transports, onRead } = createSession();
+ loadHistory.mockResolvedValueOnce(history([message(1)]));
+ markRead.mockRejectedValueOnce({ response: { status } });
+ session.start();
+ await settle();
+ events[0].onConnection("connected");
+ session.setReading(true);
+
+ await vi.advanceTimersByTimeAsync(300);
+ events[0].onMessage(message(2));
+ expect(session.getSnapshot()).toMatchObject({ accessDenied: true, messages: [], connection: "error" });
+ expect(markRead.mock.calls[0][0].aborted).toBe(true);
+ expect(transports[0].stop).toHaveBeenCalledOnce();
+ expect(onRead).not.toHaveBeenCalled();
+ expect(session.publish({ content: "차단할 전송", senderId: 7 })).toBe(false);
+ });
+});
+
+describe("ChatSession reading state after a temporary pause", () => {
+ it("resumes read updates without requiring another scroll event", async () => {
+ vi.useFakeTimers();
+ const { session, markRead } = createSession();
+ session.setReading(true);
+ session.start();
+ await settle();
+ await vi.advanceTimersByTimeAsync(300);
+ expect(markRead).toHaveBeenCalledOnce();
+ session.stop();
+ session.start();
+ await settle();
+ await vi.advanceTimersByTimeAsync(300);
+ expect(markRead).toHaveBeenCalledTimes(2);
+ vi.useRealTimers();
+ });
+});
diff --git a/apps/web/src/lib/chat/ChatSession.ts b/apps/web/src/lib/chat/ChatSession.ts
new file mode 100644
index 000000000..9c0c6c46a
--- /dev/null
+++ b/apps/web/src/lib/chat/ChatSession.ts
@@ -0,0 +1,415 @@
+import type { ChatHistoriesResponse } from "@/apis/chat";
+import type { ChatMessage } from "@/types/chat";
+import { getMessageKey, mergeMessages } from "./messages";
+import type { ChatPayload, ChatSnapshot, ChatTransport, ChatTransportEvents, OutgoingMessage } from "./types";
+
+interface ChatSessionOptions {
+ userId: number;
+ loadHistory(page: number, signal: AbortSignal): Promise;
+ markRead(signal: AbortSignal): Promise;
+ createTransport(events: ChatTransportEvents): ChatTransport;
+ initialOutgoing?: OutgoingMessage[];
+ saveOutgoing(messages: OutgoingMessage[]): void;
+ onMessagesChanged(): void;
+ onRead(): void;
+ confirmationTimeoutMs?: number;
+}
+
+const getErrorMessage = (error: unknown) => {
+ const status = (error as { response?: { status?: number } })?.response?.status;
+ if (status === 403 || status === 404) return "이 채팅방에 접근할 수 없습니다.";
+ return "메시지를 불러오지 못했어요. 다시 시도해주세요.";
+};
+
+const imageKey = (url: string) => {
+ try {
+ return new URL(url).pathname;
+ } catch {
+ return url;
+ }
+};
+
+// One owner for a room's timeline. HTTP pages and socket events only merge into this snapshot.
+export class ChatSession {
+ private snapshot: ChatSnapshot;
+ private readonly listeners = new Set<() => void>();
+ private transport: ChatTransport | null = null;
+ private controller: AbortController | null = null;
+ private generation = 0;
+ private nextOlderPage: number | null = null;
+ private reading = false;
+ private readVersion = 0;
+ private lastReadVersion = -1;
+ private readInFlight = false;
+ private readTimer: ReturnType | null = null;
+ private readonly confirmationTimers = new Map>();
+ private syncPromise: Promise | null = null;
+ private resyncRequested = false;
+ private readonly synchronizedKeys = new Set();
+ private readonly receivedSocketKeys = new Set();
+ private readonly confirmationBaselines = new Map>();
+ private hasSynchronizedHistory = false;
+
+ constructor(private readonly options: ChatSessionOptions) {
+ this.snapshot = {
+ messages: [],
+ outgoing: (options.initialOutgoing ?? []).map((item) => ({ ...item, status: "unconfirmed" })),
+ connection: "connecting",
+ historyStatus: "loading",
+ historyError: null,
+ syncError: null,
+ olderError: null,
+ isSyncing: false,
+ isLoadingOlder: false,
+ hasMore: false,
+ accessDenied: false,
+ readError: false,
+ };
+ }
+
+ getSnapshot = () => this.snapshot;
+
+ subscribe = (listener: () => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ private update(patch: Partial) {
+ this.snapshot = { ...this.snapshot, ...patch };
+ for (const listener of this.listeners) listener();
+ }
+
+ private updateOutgoing(outgoing: OutgoingMessage[]) {
+ const activeIds = new Set(outgoing.map((item) => item.clientId));
+ for (const id of this.confirmationBaselines.keys()) {
+ if (!activeIds.has(id)) this.confirmationBaselines.delete(id);
+ }
+ this.options.saveOutgoing(outgoing);
+ this.update({ outgoing });
+ }
+
+ private isCurrent(generation: number) {
+ return this.controller !== null && !this.controller.signal.aborted && this.generation === generation;
+ }
+
+ private denyAccess() {
+ this.stop();
+ this.update({
+ accessDenied: true,
+ connection: "error",
+ messages: [],
+ hasMore: false,
+ historyStatus: "error",
+ historyError: "이 채팅방에 접근할 수 없습니다.",
+ syncError: null,
+ });
+ }
+
+ private isAccessError(error: unknown) {
+ const status = (error as { response?: { status?: number } })?.response?.status;
+ return status === 403 || status === 404;
+ }
+
+ start() {
+ if (this.controller) return;
+ const generation = ++this.generation;
+ this.controller = new AbortController();
+ this.update({ connection: "connecting", accessDenied: false });
+ this.transport = this.options.createTransport({
+ onConnection: (connection) => {
+ if (this.isCurrent(generation)) this.update({ connection });
+ },
+ onConnected: () => {
+ if (this.isCurrent(generation)) void this.syncLatest();
+ },
+ onMessage: (message) => {
+ if (this.isCurrent(generation)) this.receive(message);
+ },
+ });
+ this.transport.start();
+ void this.syncLatest();
+ }
+
+ stop() {
+ this.generation += 1;
+ this.controller?.abort();
+ this.controller = null;
+ this.transport?.stop();
+ this.transport = null;
+ this.syncPromise = null;
+ this.resyncRequested = false;
+ this.readInFlight = false;
+ if (this.readTimer) clearTimeout(this.readTimer);
+ this.readTimer = null;
+ for (const timer of this.confirmationTimers.values()) clearTimeout(timer);
+ this.confirmationTimers.clear();
+ this.updateOutgoing(this.snapshot.outgoing.map((item) => ({ ...item, status: "unconfirmed" })));
+ this.update({ connection: "disconnected", isSyncing: false, isLoadingOlder: false });
+ }
+
+ reconnect = () => {
+ if (this.snapshot.accessDenied) return;
+ this.transport?.reconnect();
+ };
+
+ syncLatest = (): Promise => {
+ if (!this.controller || this.snapshot.accessDenied) return Promise.resolve();
+ if (this.syncPromise) {
+ // The subscription may become ready after the initial HTTP snapshot was taken.
+ this.resyncRequested = true;
+ return this.syncPromise;
+ }
+ const generation = this.generation;
+ const signal = this.controller.signal;
+ // Live messages can arrive across a gap. Only a completed HTTP sync is an overlap boundary.
+ const previouslySeen = new Set(this.synchronizedKeys);
+ const initialLoad = !this.hasSynchronizedHistory;
+ this.update({ isSyncing: true, historyError: null, syncError: null });
+
+ const request = async () => {
+ try {
+ let page = 1;
+ const visited = new Set();
+ const fetchedKeys = new Set();
+ let followingPage = -1;
+ while (true) {
+ if (visited.has(page)) throw new Error("반복되는 채팅 페이지입니다.");
+ visited.add(page);
+ const result = await this.options.loadHistory(page, signal);
+ if (!this.isCurrent(generation)) return;
+ followingPage = result.nextPageNumber;
+ const reachedKnownMessage = result.content.some((message) => previouslySeen.has(getMessageKey(message)));
+ for (const message of result.content) fetchedKeys.add(getMessageKey(message));
+ this.update({ messages: mergeMessages(this.snapshot.messages, result.content), historyStatus: "ready" });
+ if (initialLoad && page === 1)
+ this.nextOlderPage = result.nextPageNumber === -1 ? null : result.nextPageNumber;
+ if (result.nextPageNumber === -1) {
+ this.nextOlderPage = null;
+ break;
+ }
+ if ((initialLoad && previouslySeen.size === 0) || reachedKnownMessage) break;
+ if (!Number.isSafeInteger(result.nextPageNumber) || result.nextPageNumber <= page) {
+ throw new Error("잘못된 채팅 페이지입니다.");
+ }
+ page = result.nextPageNumber;
+ }
+ if (!initialLoad && this.nextOlderPage !== null && followingPage !== -1) {
+ this.nextOlderPage = Math.max(this.nextOlderPage, followingPage);
+ }
+ for (const key of fetchedKeys) this.synchronizedKeys.add(key);
+ this.hasSynchronizedHistory = true;
+ this.update({ hasMore: this.nextOlderPage !== null });
+ this.readVersion += 1;
+ this.queueRead();
+ this.options.onMessagesChanged();
+ } catch (error) {
+ if (!this.isCurrent(generation)) return;
+ if (this.isAccessError(error)) {
+ this.denyAccess();
+ return;
+ }
+ const hasMessages = this.snapshot.messages.length > 0;
+ this.update({
+ historyStatus: hasMessages ? "ready" : "error",
+ historyError: hasMessages ? null : getErrorMessage(error),
+ syncError: hasMessages ? getErrorMessage(error) : null,
+ });
+ } finally {
+ if (this.isCurrent(generation)) {
+ this.syncPromise = null;
+ this.update({ isSyncing: false });
+ if (this.resyncRequested) {
+ this.resyncRequested = false;
+ void this.syncLatest();
+ }
+ }
+ }
+ };
+ this.syncPromise = request();
+ return this.syncPromise;
+ };
+
+ loadOlder = async () => {
+ if (!this.controller || this.nextOlderPage === null || this.snapshot.isLoadingOlder || this.snapshot.accessDenied)
+ return;
+ const generation = this.generation;
+ let page = this.nextOlderPage;
+ this.update({ isLoadingOlder: true, olderError: null });
+ try {
+ while (true) {
+ const result = await this.options.loadHistory(page, this.controller.signal);
+ if (!this.isCurrent(generation)) return;
+ if (result.nextPageNumber !== -1 && result.nextPageNumber <= page) throw new Error("잘못된 채팅 페이지입니다.");
+ const knownKeys = new Set(this.snapshot.messages.map(getMessageKey));
+ const hasNewMessages = result.content.some((message) => !knownKeys.has(getMessageKey(message)));
+ this.nextOlderPage =
+ result.nextPageNumber === -1 || this.nextOlderPage === null
+ ? null
+ : Math.max(this.nextOlderPage, result.nextPageNumber);
+ this.update({
+ messages: mergeMessages(this.snapshot.messages, result.content),
+ hasMore: this.nextOlderPage !== null,
+ });
+ if (hasNewMessages || this.nextOlderPage === null) break;
+ // Offset pages can overlap after new arrivals. One click continues until older messages are found.
+ page = this.nextOlderPage;
+ }
+ } catch (error) {
+ if (!this.isCurrent(generation)) return;
+ if (this.isAccessError(error)) this.denyAccess();
+ else this.update({ olderError: getErrorMessage(error) });
+ } finally {
+ if (this.isCurrent(generation)) this.update({ isLoadingOlder: false });
+ }
+ };
+
+ private receive(message: ChatMessage) {
+ const key = getMessageKey(message);
+ const isDuplicate = this.snapshot.messages.some((existing) => getMessageKey(existing) === key);
+ const firstSocketDelivery = !this.receivedSocketKeys.has(key);
+ this.receivedSocketKeys.add(key);
+ this.update({ messages: mergeMessages(this.snapshot.messages, [message]) });
+ // History can arrive before the first echo. Only socket replays have already consumed a confirmation.
+ if (firstSocketDelivery && message.id > 0 && message.senderId === this.options.userId)
+ this.confirmOutgoing(message);
+ if (!isDuplicate) {
+ this.readVersion += 1;
+ this.queueRead();
+ this.options.onMessagesChanged();
+ }
+ }
+
+ private confirmOutgoing(message: ChatMessage) {
+ const imageUrls = message.attachments.filter((item) => item.isImage).map((item) => imageKey(item.url));
+ const index = this.snapshot.outgoing.findIndex((item) => {
+ // Restored outbox items have no proof tying a new echo to their original send.
+ const baseline = this.confirmationBaselines.get(item.clientId);
+ if (!baseline || baseline.has(getMessageKey(message))) return false;
+ if (item.imageUrls.length > 0) return item.remainingImageUrls.some((url) => imageUrls.includes(imageKey(url)));
+ return imageUrls.length === 0 && item.content.trim() === message.content.trim();
+ });
+ if (index === -1) return;
+ const outgoing = [...this.snapshot.outgoing];
+ const item = outgoing[index];
+ const remainingImageUrls = item.remainingImageUrls.filter((url) => !imageUrls.includes(imageKey(url)));
+ if (remainingImageUrls.length > 0) {
+ outgoing[index] = { ...item, remainingImageUrls };
+ } else {
+ const timer = this.confirmationTimers.get(item.clientId);
+ if (timer) clearTimeout(timer);
+ this.confirmationTimers.delete(item.clientId);
+ outgoing.splice(index, 1);
+ }
+ this.updateOutgoing(outgoing);
+ }
+
+ publish = (payload: ChatPayload): boolean => {
+ if (!this.controller || this.snapshot.accessDenied || this.snapshot.connection !== "connected") return false;
+ if ("content" in payload && !payload.content.trim()) return false;
+ if ("imageUrls" in payload && payload.imageUrls.length === 0) return false;
+ const clientId = crypto.randomUUID();
+ const outgoing: OutgoingMessage = {
+ clientId,
+ content: "content" in payload ? payload.content : "",
+ imageUrls: "imageUrls" in payload ? [...payload.imageUrls] : [],
+ remainingImageUrls: "imageUrls" in payload ? [...payload.imageUrls] : [],
+ senderId: this.options.userId,
+ createdAt: new Date().toISOString(),
+ status: "pending",
+ };
+ this.confirmationBaselines.set(clientId, new Set(this.snapshot.messages.map(getMessageKey)));
+ this.updateOutgoing([...this.snapshot.outgoing, outgoing]);
+ try {
+ if (!this.transport) throw new Error("연결이 없습니다.");
+ this.transport.publish(payload);
+ if (this.snapshot.outgoing.some((item) => item.clientId === clientId)) {
+ this.confirmationTimers.set(
+ clientId,
+ setTimeout(() => {
+ this.confirmationTimers.delete(clientId);
+ this.updateOutgoing(
+ this.snapshot.outgoing.map((item) =>
+ item.clientId === clientId ? { ...item, status: "unconfirmed" } : item,
+ ),
+ );
+ }, this.options.confirmationTimeoutMs ?? 15000),
+ );
+ }
+ return true;
+ } catch {
+ this.updateOutgoing(this.snapshot.outgoing.filter((item) => item.clientId !== clientId));
+ return false;
+ }
+ };
+
+ sendImages = async (
+ files: File[],
+ upload: (files: File[], signal: AbortSignal) => Promise,
+ ): Promise => {
+ if (!this.controller || this.snapshot.connection !== "connected" || this.snapshot.accessDenied) return false;
+ const generation = this.generation;
+ const urls = await upload(files, this.controller.signal);
+ // An upload belongs to the room and login session in which it started.
+ if (!this.isCurrent(generation)) return false;
+ if (urls.length !== files.length || urls.some((url) => !url))
+ throw new Error("이미지 업로드 결과를 확인할 수 없습니다.");
+ return this.publish({ imageUrls: urls });
+ };
+
+ dismissOutgoing = (clientId: string) => {
+ const timer = this.confirmationTimers.get(clientId);
+ if (timer) clearTimeout(timer);
+ this.confirmationTimers.delete(clientId);
+ this.updateOutgoing(this.snapshot.outgoing.filter((item) => item.clientId !== clientId));
+ };
+
+ setReading = (reading: boolean) => {
+ this.reading = reading;
+ if (reading) this.queueRead();
+ };
+
+ retryRead = () => {
+ this.update({ readError: false });
+ this.queueRead();
+ };
+
+ private queueRead() {
+ if (
+ !this.controller ||
+ !this.reading ||
+ this.snapshot.historyStatus !== "ready" ||
+ this.snapshot.accessDenied ||
+ this.readInFlight ||
+ this.readTimer ||
+ this.lastReadVersion === this.readVersion
+ )
+ return;
+ const generation = this.generation;
+ this.readTimer = setTimeout(() => {
+ this.readTimer = null;
+ void this.flushRead(generation);
+ }, 300);
+ }
+
+ private async flushRead(generation: number) {
+ if (!this.isCurrent(generation) || !this.reading || !this.controller) return;
+ const version = this.readVersion;
+ this.readInFlight = true;
+ try {
+ await this.options.markRead(this.controller.signal);
+ if (!this.isCurrent(generation)) return;
+ this.lastReadVersion = version;
+ this.update({ readError: false });
+ this.options.onRead();
+ } catch (error) {
+ if (!this.isCurrent(generation)) return;
+ if (this.isAccessError(error)) this.denyAccess();
+ else this.update({ readError: true });
+ } finally {
+ if (this.isCurrent(generation)) {
+ this.readInFlight = false;
+ if (!this.snapshot.readError) this.queueRead();
+ }
+ }
+ }
+}
diff --git a/apps/web/src/lib/chat/messages.ts b/apps/web/src/lib/chat/messages.ts
new file mode 100644
index 000000000..08203f8e9
--- /dev/null
+++ b/apps/web/src/lib/chat/messages.ts
@@ -0,0 +1,35 @@
+import type { ChatMessage } from "@/types/chat";
+
+export const getMessageKey = (message: ChatMessage): string => {
+ if (message.id > 0) return `id:${message.id}`;
+
+ return `message:${JSON.stringify([
+ message.senderId,
+ message.createdAt,
+ message.content,
+ message.attachments.map((attachment) => [
+ attachment.id,
+ attachment.isImage,
+ attachment.url,
+ attachment.thumbnailUrl,
+ attachment.createdAt,
+ ]),
+ ])}`;
+};
+
+export const mergeMessages = (existing: readonly ChatMessage[], incoming: readonly ChatMessage[]): ChatMessage[] => {
+ const messagesByKey = new Map();
+
+ for (const message of existing) messagesByKey.set(getMessageKey(message), message);
+ for (const message of incoming) messagesByKey.set(getMessageKey(message), message);
+
+ return [...messagesByKey.values()].sort((first, second) => {
+ const timeDifference = Date.parse(first.createdAt) - Date.parse(second.createdAt);
+ if (timeDifference !== 0) return timeDifference;
+
+ const idDifference = first.id - second.id;
+ if (idDifference !== 0) return idDifference;
+
+ return getMessageKey(first).localeCompare(getMessageKey(second));
+ });
+};
diff --git a/apps/web/src/lib/chat/stompTransport.test.ts b/apps/web/src/lib/chat/stompTransport.test.ts
new file mode 100644
index 000000000..fb608530b
--- /dev/null
+++ b/apps/web/src/lib/chat/stompTransport.test.ts
@@ -0,0 +1,183 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createStompTransport } from "./stompTransport";
+import type { ChatTransport } from "./types";
+
+interface MockClient {
+ options: { webSocketFactory(): unknown };
+ connected: boolean;
+ beforeConnect?: () => Promise;
+ onConnect?: () => void;
+ onWebSocketClose?: () => void;
+ onStompError?: () => void;
+ subscriptions: Map void>;
+ activate: ReturnType;
+ deactivate: ReturnType;
+ publish: ReturnType;
+}
+
+const { clients, socketFactory } = vi.hoisted(() => ({
+ clients: [] as MockClient[],
+ socketFactory: vi.fn(function createSocket(url: string) {
+ return { url };
+ }),
+}));
+
+vi.mock("sockjs-client", () => ({ default: socketFactory }));
+vi.mock("@stomp/stompjs", () => ({
+ Client: class {
+ connected = false;
+ subscriptions = new Map void>();
+ activate = vi.fn();
+ deactivate = vi.fn(async () => {
+ this.connected = false;
+ });
+ publish = vi.fn();
+ constructor(public options: { webSocketFactory(): unknown }) {
+ clients.push(this);
+ }
+ subscribe(destination: string, callback: (frame: { body: string }) => void) {
+ this.subscriptions.set(destination, callback);
+ return { unsubscribe: vi.fn() };
+ }
+ },
+}));
+
+const transports: ChatTransport[] = [];
+
+function createTransport(getAccessToken = vi.fn<() => Promise>().mockResolvedValue("first token")) {
+ const onConnection = vi.fn();
+ const onConnected = vi.fn();
+ const onMessage = vi.fn();
+ const transport = createStompTransport({ roomId: 42, getAccessToken, onConnection, onConnected, onMessage });
+ transports.push(transport);
+ return { transport, getAccessToken, onConnection, onConnected, onMessage };
+}
+
+beforeEach(() => {
+ clients.splice(0);
+ socketFactory.mockClear();
+ vi.stubEnv("NEXT_PUBLIC_API_SERVER_URL", "https://api.example.com/");
+ vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(true);
+});
+
+afterEach(() => {
+ for (const transport of transports.splice(0)) transport.stop();
+ vi.unstubAllEnvs();
+});
+
+describe("STOMP transport lifecycle", () => {
+ it("obtains a fresh token before every connect and encodes it in the SockJS URL", async () => {
+ const { transport, getAccessToken, onConnection, onConnected } = createTransport();
+ getAccessToken.mockResolvedValueOnce("first token").mockResolvedValueOnce("new/token?");
+ transport.start();
+ transport.start();
+ expect(clients).toHaveLength(1);
+ const client = clients[0];
+ await client.beforeConnect?.();
+ client.options.webSocketFactory();
+ client.connected = true;
+ client.onConnect?.();
+ await client.beforeConnect?.();
+ client.options.webSocketFactory();
+
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
+ expect(socketFactory.mock.calls.map(([url]) => url)).toEqual([
+ "https://api.example.com/connect?token=first%20token",
+ "https://api.example.com/connect?token=new%2Ftoken%3F",
+ ]);
+ expect(onConnected).toHaveBeenCalledOnce();
+ expect(onConnection.mock.calls.map(([state]) => state)).toEqual(["connecting", "connected", "reconnecting"]);
+ });
+
+ it("rejects a stopped socket factory when token acquisition resolves late", async () => {
+ let resolveToken!: (token: string) => void;
+ const getAccessToken = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveToken = resolve;
+ }),
+ );
+ const { transport, onConnection, onConnected, onMessage } = createTransport(getAccessToken);
+ transport.start();
+ const client = clients[0];
+ const connecting = client.beforeConnect?.();
+ transport.stop();
+ resolveToken("late token");
+ await connecting;
+ client.onConnect?.();
+
+ expect(() => client.options.webSocketFactory()).toThrow("채팅 연결이 종료되었습니다.");
+ expect(client.deactivate).toHaveBeenCalledOnce();
+ expect(socketFactory).not.toHaveBeenCalled();
+ expect(onConnected).not.toHaveBeenCalled();
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(onConnection.mock.calls.map(([state]) => state)).toEqual(["connecting"]);
+ });
+
+ it("disposes offline connections and removes online listeners when stopped", () => {
+ const online = vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(true);
+ const addListener = vi.spyOn(window, "addEventListener");
+ const removeListener = vi.spyOn(window, "removeEventListener");
+ const { transport, onConnection } = createTransport();
+ transport.start();
+ online.mockReturnValue(false);
+ window.dispatchEvent(new Event("offline"));
+ expect(clients[0].deactivate).toHaveBeenCalledOnce();
+ expect(onConnection).toHaveBeenLastCalledWith("offline");
+
+ online.mockReturnValue(true);
+ window.dispatchEvent(new Event("online"));
+ expect(clients).toHaveLength(2);
+ transport.stop();
+ window.dispatchEvent(new Event("online"));
+ expect(clients).toHaveLength(2);
+ for (const type of ["offline", "online"]) {
+ const added = addListener.mock.calls.find(([eventType]) => eventType === type);
+ expect(removeListener).toHaveBeenCalledWith(type, added?.[1]);
+ }
+ });
+
+ it("ignores malformed frames and callbacks belonging to a replaced connection", async () => {
+ const { transport, onMessage } = createTransport();
+ transport.start();
+ const original = clients[0];
+ await original.beforeConnect?.();
+ original.onConnect?.();
+ const callback = original.subscriptions.get("/topic/chat/42");
+ callback?.({ body: "{" });
+ callback?.({ body: "null" });
+ callback?.({ body: JSON.stringify({ id: 1, content: "안녕", senderId: 7, createdAt: "2026-10-27T00:00:00Z" }) });
+ expect(onMessage).toHaveBeenCalledOnce();
+
+ transport.reconnect();
+ callback?.({
+ body: JSON.stringify({ id: 2, content: "늦은 메시지", senderId: 7, createdAt: "2026-10-27T00:00:01Z" }),
+ });
+ original.onConnect?.();
+ expect(onMessage).toHaveBeenCalledOnce();
+ });
+
+ it("publishes only while connected and never replays a message on reconnect", async () => {
+ const { transport } = createTransport();
+ expect(() => transport.publish({ content: "보관", senderId: 7 })).toThrow();
+ transport.start();
+ const client = clients[0];
+ await client.beforeConnect?.();
+ client.connected = true;
+ client.onConnect?.();
+ transport.publish({ content: "텍스트", senderId: 7 });
+ transport.publish({ imageUrls: ["https://cdn.example.com/image.png"] });
+ expect(client.publish.mock.calls.map(([payload]) => payload)).toEqual([
+ { destination: "/publish/chat/42", body: JSON.stringify({ content: "텍스트", senderId: 7 }) },
+ {
+ destination: "/publish/chat/42/image",
+ body: JSON.stringify({ imageUrls: ["https://cdn.example.com/image.png"] }),
+ },
+ ]);
+ transport.reconnect();
+ expect(clients[1].publish).not.toHaveBeenCalled();
+ transport.stop();
+ expect(() => transport.publish({ content: "보관", senderId: 7 })).toThrow();
+ });
+});
diff --git a/apps/web/src/lib/chat/stompTransport.ts b/apps/web/src/lib/chat/stompTransport.ts
new file mode 100644
index 000000000..df8ac1e1a
--- /dev/null
+++ b/apps/web/src/lib/chat/stompTransport.ts
@@ -0,0 +1,128 @@
+import { Client } from "@stomp/stompjs";
+import SockJS from "sockjs-client";
+import { parseChatMessage } from "@/apis/chat/normalize";
+import type { ChatPayload, ChatTransport, ChatTransportEvents } from "./types";
+
+interface StompTransportOptions extends ChatTransportEvents {
+ roomId: number;
+ getAccessToken(): Promise;
+}
+
+// Every reconnect obtains a new token. No message is replayed by the transport.
+export const createStompTransport = (options: StompTransportOptions): ChatTransport => {
+ let current: Client | null = null;
+ let running = false;
+ let generation = 0;
+ let hasConnected = false;
+
+ const disposeClient = () => {
+ generation += 1;
+ const previous = current;
+ current = null;
+ if (previous) void previous.deactivate();
+ };
+
+ const connect = () => {
+ disposeClient();
+ if (!running) return;
+ if (!navigator.onLine) {
+ options.onConnection("offline");
+ return;
+ }
+
+ const connectionGeneration = generation;
+ let token = "";
+ const isCurrent = () => running && connectionGeneration === generation;
+ const client = new Client({
+ webSocketFactory: () => {
+ if (!isCurrent() || !token) throw new Error("채팅 연결이 종료되었습니다.");
+ const apiUrl = process.env.NEXT_PUBLIC_API_SERVER_URL;
+ if (!apiUrl) throw new Error("채팅 서버 주소를 확인할 수 없습니다.");
+ return new SockJS(`${apiUrl.replace(/\/$/, "")}/connect?token=${encodeURIComponent(token)}`);
+ },
+ connectionTimeout: 10000,
+ reconnectDelay: 3000,
+ heartbeatIncoming: 20000,
+ heartbeatOutgoing: 20000,
+ });
+
+ client.beforeConnect = async () => {
+ if (!isCurrent()) return;
+ options.onConnection(hasConnected ? "reconnecting" : "connecting");
+ try {
+ const latestToken = await options.getAccessToken();
+ if (!isCurrent()) return;
+ if (!latestToken) throw new Error("로그인이 필요합니다.");
+ token = latestToken;
+ } catch {
+ if (!isCurrent()) return;
+ options.onConnection("error");
+ disposeClient();
+ }
+ };
+
+ client.onConnect = () => {
+ if (!isCurrent()) return;
+ hasConnected = true;
+ client.subscribe(`/topic/chat/${options.roomId}`, (frame) => {
+ if (!isCurrent()) return;
+ try {
+ const message = parseChatMessage(JSON.parse(frame.body));
+ if (message) options.onMessage(message);
+ } catch {
+ // A malformed frame must not tear down a working subscription.
+ }
+ });
+ options.onConnection("connected");
+ options.onConnected();
+ };
+ client.onWebSocketClose = () => {
+ if (isCurrent()) options.onConnection(navigator.onLine ? "reconnecting" : "offline");
+ };
+ client.onWebSocketError = () => {
+ if (isCurrent()) options.onConnection(navigator.onLine ? "reconnecting" : "offline");
+ };
+ client.onStompError = () => {
+ if (!isCurrent()) return;
+ options.onConnection("error");
+ // Broker errors require an explicit retry instead of an endless reconnect loop.
+ disposeClient();
+ };
+ current = client;
+ client.activate();
+ };
+
+ const onOffline = () => {
+ disposeClient();
+ if (running) options.onConnection("offline");
+ };
+ const onOnline = () => connect();
+
+ return {
+ start() {
+ if (running) return;
+ running = true;
+ window.addEventListener("offline", onOffline);
+ window.addEventListener("online", onOnline);
+ connect();
+ },
+ stop() {
+ running = false;
+ window.removeEventListener("offline", onOffline);
+ window.removeEventListener("online", onOnline);
+ disposeClient();
+ },
+ reconnect() {
+ if (running) connect();
+ },
+ publish(payload: ChatPayload) {
+ if (!running || !current?.connected || !navigator.onLine) {
+ throw new Error("채팅 연결을 확인해주세요. 입력한 내용은 보관됩니다.");
+ }
+ current.publish({
+ destination: `/publish/chat/${options.roomId}${"imageUrls" in payload ? "/image" : ""}`,
+ body: JSON.stringify(payload),
+ });
+ },
+ };
+};
diff --git a/apps/web/src/lib/chat/types.ts b/apps/web/src/lib/chat/types.ts
new file mode 100644
index 000000000..c99d9603b
--- /dev/null
+++ b/apps/web/src/lib/chat/types.ts
@@ -0,0 +1,43 @@
+import type { ChatMessage } from "@/types/chat";
+
+export type ChatConnection = "connecting" | "connected" | "reconnecting" | "offline" | "error" | "disconnected";
+
+export type OutgoingMessage = {
+ clientId: string;
+ content: string;
+ imageUrls: string[];
+ remainingImageUrls: string[];
+ senderId: number;
+ createdAt: string;
+ status: "pending" | "unconfirmed";
+};
+
+export type ChatSnapshot = {
+ messages: ChatMessage[];
+ outgoing: OutgoingMessage[];
+ connection: ChatConnection;
+ historyStatus: "loading" | "ready" | "error";
+ historyError: string | null;
+ syncError: string | null;
+ olderError: string | null;
+ isSyncing: boolean;
+ isLoadingOlder: boolean;
+ hasMore: boolean;
+ accessDenied: boolean;
+ readError: boolean;
+};
+
+export type ChatPayload = { content: string; senderId: number } | { imageUrls: string[] };
+
+export interface ChatTransport {
+ start(): void;
+ stop(): void;
+ reconnect(): void;
+ publish(payload: ChatPayload): void;
+}
+
+export interface ChatTransportEvents {
+ onConnection(state: ChatConnection): void;
+ onConnected(): void;
+ onMessage(message: ChatMessage): void;
+}
diff --git a/apps/web/src/lib/chat/useChatScroll.test.tsx b/apps/web/src/lib/chat/useChatScroll.test.tsx
new file mode 100644
index 000000000..047522395
--- /dev/null
+++ b/apps/web/src/lib/chat/useChatScroll.test.tsx
@@ -0,0 +1,160 @@
+// @vitest-environment jsdom
+import { act, fireEvent, render, screen } from "@testing-library/react/pure";
+import { beforeEach, expect, it, vi } from "vitest";
+import type { ChatMessage } from "@/types/chat";
+import { getMessageKey } from "./messages";
+import { useChatScroll } from "./useChatScroll";
+
+interface Geometry {
+ scrollTop: number;
+ extraHeight: number;
+}
+
+const resizeCallbacks: Array<() => void> = [];
+
+beforeEach(() => {
+ resizeCallbacks.length = 0;
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(callback: () => void) {
+ resizeCallbacks.push(callback);
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+});
+
+const messages = (from: number, to: number): ChatMessage[] =>
+ Array.from({ length: to - from + 1 }, (_, index) => ({
+ id: from + index,
+ senderId: 2,
+ content: `메시지 ${from + index}`,
+ createdAt: new Date(Date.UTC(2026, 9, 27, 0, 0, from + index)).toISOString(),
+ attachments: [],
+ }));
+
+const Harness = ({
+ items,
+ geometry,
+ setReading,
+ isLoadingOlder = false,
+}: {
+ items: ChatMessage[];
+ geometry: Geometry;
+ setReading: (reading: boolean) => void;
+ isLoadingOlder?: boolean;
+}) => {
+ const scroll = useChatScroll({ messages: items, outgoingCount: 0, isLoadingOlder, setReading });
+
+ return (
+ <>
+
+ {
+ scroll.containerRef.current = element;
+ if (!element) return;
+ Object.defineProperties(element, {
+ scrollHeight: { configurable: true, get: () => items.length * 100 + geometry.extraHeight },
+ clientHeight: { configurable: true, get: () => 300 },
+ scrollTop: {
+ configurable: true,
+ get: () => geometry.scrollTop,
+ set: (value: number) => {
+ geometry.scrollTop = Math.max(0, Math.min(value, items.length * 100 + geometry.extraHeight - 300));
+ },
+ },
+ });
+ element.getBoundingClientRect = () => new DOMRect(0, 0, 300, 300);
+ }}
+ >
+
+ {items.map((message, index) => (
+
{
+ if (element)
+ element.getBoundingClientRect = () => new DOMRect(0, index * 100 - geometry.scrollTop, 300, 100);
+ }}
+ >
+ {message.content}
+
+ ))}
+
+
+ {scroll.hasNewMessages && (
+
+ )}
+ >
+ );
+};
+
+it("opens at the newest message and follows delayed content growth only while at the bottom", () => {
+ const geometry = { scrollTop: 0, extraHeight: 0 };
+ const setReading = vi.fn();
+ const { unmount } = render();
+ expect(geometry.scrollTop).toBe(700);
+ expect(setReading).toHaveBeenLastCalledWith(true);
+
+ geometry.extraHeight = 150;
+ act(() => {
+ resizeCallbacks[0]();
+ });
+ expect(geometry.scrollTop).toBe(850);
+ unmount();
+ expect(setReading).toHaveBeenLastCalledWith(false);
+});
+
+it("preserves the history reader's position on new incoming messages and offers a jump to latest", () => {
+ const geometry = { scrollTop: 0, extraHeight: 0 };
+ const setReading = vi.fn();
+ const { rerender } = render();
+ geometry.scrollTop = 200;
+ fireEvent.scroll(screen.getByTestId("scroll-container"));
+ expect(setReading).toHaveBeenLastCalledWith(false);
+
+ rerender();
+ expect(geometry.scrollTop).toBe(200);
+ expect(screen.getByRole("button", { name: "새 메시지 보기" })).toBeTruthy();
+ geometry.extraHeight = 100;
+ act(() => {
+ resizeCallbacks[0]();
+ });
+ expect(geometry.scrollTop).toBe(200);
+
+ fireEvent.click(screen.getByRole("button", { name: "새 메시지 보기" }));
+ expect(geometry.scrollTop).toBe(900);
+ expect(screen.queryByRole("button", { name: "새 메시지 보기" })).toBeNull();
+ expect(setReading).toHaveBeenLastCalledWith(true);
+});
+
+it("keeps the same visible message offset when older history is prepended and releases its anchor afterwards", () => {
+ const geometry = { scrollTop: 0, extraHeight: 0 };
+ const setReading = vi.fn();
+ const { rerender } = render();
+ geometry.scrollTop = 250;
+ fireEvent.scroll(screen.getByTestId("scroll-container"));
+ const offsetBefore = screen.getByText("메시지 13").getBoundingClientRect().top;
+ fireEvent.click(screen.getByRole("button", { name: "이전 기록 준비" }));
+ rerender();
+ expect(geometry.scrollTop).toBe(250);
+
+ rerender();
+ expect(geometry.scrollTop).toBe(1250);
+ expect(screen.getByText("메시지 13").getBoundingClientRect().top).toBe(offsetBefore);
+ expect(screen.queryByRole("button", { name: "새 메시지 보기" })).toBeNull();
+
+ rerender();
+ expect(geometry.scrollTop).toBe(1250);
+ expect(screen.getByRole("button", { name: "새 메시지 보기" })).toBeTruthy();
+});
diff --git a/apps/web/src/lib/chat/useChatScroll.ts b/apps/web/src/lib/chat/useChatScroll.ts
new file mode 100644
index 000000000..66458c3f1
--- /dev/null
+++ b/apps/web/src/lib/chat/useChatScroll.ts
@@ -0,0 +1,110 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import type { ChatMessage } from "@/types/chat";
+import { getMessageKey } from "./messages";
+
+interface ScrollOptions {
+ messages: ChatMessage[];
+ outgoingCount: number;
+ isLoadingOlder: boolean;
+ setReading(reading: boolean): void;
+}
+
+const BOTTOM_THRESHOLD_PX = 80;
+
+export const useChatScroll = ({ messages, outgoingCount, isLoadingOlder, setReading }: ScrollOptions) => {
+ const containerRef = useRef(null);
+ const contentRef = useRef(null);
+ const atBottomRef = useRef(true);
+ const initializedRef = useRef(false);
+ const previousLastKeyRef = useRef(null);
+ const previousOutgoingCountRef = useRef(outgoingCount);
+ const anchorRef = useRef<{ key: string; offset: number } | null>(null);
+ const [hasNewMessages, setHasNewMessages] = useState(false);
+
+ const updateReading = useCallback(() => {
+ setReading(document.visibilityState === "visible" && atBottomRef.current);
+ }, [setReading]);
+
+ const scrollToBottom = useCallback(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ container.scrollTop = container.scrollHeight;
+ atBottomRef.current = true;
+ setHasNewMessages(false);
+ updateReading();
+ }, [updateReading]);
+
+ const onScroll = useCallback(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ atBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < BOTTOM_THRESHOLD_PX;
+ if (atBottomRef.current) setHasNewMessages(false);
+ updateReading();
+ }, [updateReading]);
+
+ const captureAnchor = useCallback(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const top = container.getBoundingClientRect().top;
+ const visible = Array.from(container.querySelectorAll("[data-message-key]")).find(
+ (element) => element.getBoundingClientRect().bottom > top,
+ );
+ if (visible?.dataset.messageKey) {
+ anchorRef.current = { key: visible.dataset.messageKey, offset: visible.getBoundingClientRect().top - top };
+ }
+ }, []);
+
+ useLayoutEffect(
+ function maintainMessagePosition() {
+ const container = containerRef.current;
+ if (!container) return;
+ const lastKey = messages.length > 0 ? getMessageKey(messages[messages.length - 1]) : null;
+ const newLastMessage = lastKey !== previousLastKeyRef.current;
+ const hasNewOutgoing = outgoingCount > previousOutgoingCountRef.current;
+
+ if (anchorRef.current) {
+ const anchor = anchorRef.current;
+ const element = Array.from(container.querySelectorAll("[data-message-key]")).find(
+ (item) => item.dataset.messageKey === anchor.key,
+ );
+ if (element)
+ container.scrollTop +=
+ element.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.offset;
+ if (!isLoadingOlder) anchorRef.current = null;
+ } else if ((!initializedRef.current && messages.length > 0) || hasNewOutgoing || atBottomRef.current) {
+ scrollToBottom();
+ } else if (newLastMessage) {
+ setHasNewMessages(true);
+ }
+
+ if (messages.length > 0) initializedRef.current = true;
+ previousLastKeyRef.current = lastKey;
+ previousOutgoingCountRef.current = outgoingCount;
+ },
+ [isLoadingOlder, messages, outgoingCount, scrollToBottom],
+ );
+
+ useEffect(
+ function observeMessageSize() {
+ const content = contentRef.current;
+ if (!content) return;
+ // Image layout changes should keep the bottom pinned only while the reader is already there.
+ const observer = new ResizeObserver(() => {
+ if (atBottomRef.current && !anchorRef.current) scrollToBottom();
+ });
+ observer.observe(content);
+ document.addEventListener("visibilitychange", updateReading);
+ updateReading();
+ return function disconnectMessageSizeObserver() {
+ observer.disconnect();
+ document.removeEventListener("visibilitychange", updateReading);
+ setReading(false);
+ };
+ },
+ [scrollToBottom, setReading, updateReading],
+ );
+
+ return { containerRef, contentRef, onScroll, captureAnchor, scrollToBottom, hasNewMessages };
+};
diff --git a/apps/web/src/lib/chat/useChatSession.test.tsx b/apps/web/src/lib/chat/useChatSession.test.tsx
new file mode 100644
index 000000000..44fefcca1
--- /dev/null
+++ b/apps/web/src/lib/chat/useChatSession.test.tsx
@@ -0,0 +1,162 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react/pure";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ChatHistoriesResponse } from "@/apis/chat";
+import { chatKeys } from "@/apis/chat/queryKeys";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import type { ChatTransportEvents } from "./types";
+import { useChatSession } from "./useChatSession";
+
+const { loadHistory, markRead, uploadImages, events, transports } = vi.hoisted(() => ({
+ loadHistory: vi.fn<() => Promise>(),
+ markRead: vi.fn<() => Promise>(),
+ uploadImages: vi.fn<() => Promise>(),
+ events: [] as ChatTransportEvents[],
+ transports: [] as {
+ start: ReturnType;
+ stop: ReturnType;
+ publish: ReturnType;
+ }[],
+}));
+
+vi.mock("@/apis/chat", async () => ({
+ chatApi: { getChatHistories: loadHistory, putReadChatRoom: markRead },
+ ...(await import("@/apis/chat/queryKeys")),
+ ...(await import("@/apis/chat/auth")),
+}));
+vi.mock("@/apis/image-upload/api", () => ({ imageUploadApi: { postUploadChatImages: uploadImages } }));
+vi.mock("@/utils/axiosInstance", () => ({ getValidAccessToken: vi.fn(async () => "token") }));
+vi.mock("./stompTransport", () => ({
+ createStompTransport: (callbacks: ChatTransportEvents) => {
+ const transport = { start: vi.fn(), stop: vi.fn(), publish: vi.fn(), reconnect: vi.fn() };
+ events.push(callbacks);
+ transports.push(transport);
+ return transport;
+ },
+}));
+
+function setup(strict = false) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ }
+ function render(roomId: number, userId = 7, enabled = true) {
+ return renderHook((props) => useChatSession(props.roomId, props.userId, props.enabled), {
+ initialProps: { roomId, userId, enabled },
+ wrapper: Wrapper,
+ reactStrictMode: strict,
+ });
+ }
+ return { queryClient, render };
+}
+
+beforeEach(() => {
+ const tokenPayload = btoa(JSON.stringify({ sub: 7, role: "MENTOR", exp: Math.floor(Date.now() / 1000) + 3600 }));
+ useAuthStore.getState().setAccessToken(`header.${tokenPayload}.signature`);
+ events.splice(0);
+ transports.splice(0);
+ loadHistory.mockReset().mockResolvedValue({ content: [], nextPageNumber: -1 });
+ markRead.mockReset().mockResolvedValue(undefined);
+ uploadImages.mockReset().mockResolvedValue([]);
+});
+
+afterEach(() => {
+ useAuthStore.getState().clearAccessToken();
+});
+
+describe("useChatSession boundaries", () => {
+ it("waits for authentication gating and stops when the gate closes", async () => {
+ const { render } = setup();
+ const hook = render(42, 7, false);
+ expect(loadHistory).not.toHaveBeenCalled();
+ expect(transports).toHaveLength(0);
+
+ hook.rerender({ roomId: 42, userId: 7, enabled: true });
+ await waitFor(() => expect(hook.result.current.snapshot.historyStatus).toBe("ready"));
+ expect(loadHistory).toHaveBeenCalledOnce();
+ expect(transports[0].start).toHaveBeenCalledOnce();
+ hook.rerender({ roomId: 42, userId: 7, enabled: false });
+ expect(transports[0].stop).toHaveBeenCalledOnce();
+ expect(hook.result.current.snapshot.connection).toBe("disconnected");
+ });
+
+ it("survives React StrictMode's effect replay and ignores the disposed connection", async () => {
+ const { render } = setup(true);
+ const hook = render(42);
+ await waitFor(() => expect(hook.result.current.snapshot.historyStatus).toBe("ready"));
+ expect(transports).toHaveLength(2);
+ expect(transports[0].stop).toHaveBeenCalledOnce();
+ expect(transports[1].start).toHaveBeenCalledOnce();
+
+ act(() => {
+ events[0].onConnection("error");
+ events[0].onMessage({
+ id: 1,
+ content: "늦은 이벤트",
+ senderId: 8,
+ createdAt: "2026-10-27T00:00:00Z",
+ attachments: [],
+ });
+ events[1].onConnection("connected");
+ events[1].onMessage({
+ id: 2,
+ content: "현재 이벤트",
+ senderId: 8,
+ createdAt: "2026-10-27T00:00:01Z",
+ attachments: [],
+ });
+ });
+ expect(hook.result.current.snapshot.messages.map((message) => message.id)).toEqual([2]);
+ expect(hook.result.current.snapshot.connection).toBe("connected");
+ });
+
+ it("restores unconfirmed original content on room revisit and isolates it by room and user", async () => {
+ const { queryClient, render } = setup();
+ const first = render(42);
+ await waitFor(() => expect(first.result.current.snapshot.historyStatus).toBe("ready"));
+ act(() => events[0].onConnection("connected"));
+ await act(async () => {
+ await expect(first.result.current.sendText("전송 원문")).resolves.toBe(true);
+ });
+ expect(first.result.current.snapshot.outgoing).toHaveLength(1);
+ first.unmount();
+
+ const revisited = render(42);
+ expect(revisited.result.current.snapshot.outgoing).toHaveLength(1);
+ expect(revisited.result.current.snapshot.outgoing[0]).toMatchObject({
+ content: "전송 원문",
+ status: "unconfirmed",
+ });
+ expect(queryClient.getQueryData(chatKeys.outbox(7, 42))).toEqual(revisited.result.current.snapshot.outgoing);
+ const anotherRoom = render(43);
+ const anotherUser = render(42, 8);
+ expect(anotherRoom.result.current.snapshot.outgoing).toEqual([]);
+ expect(anotherUser.result.current.snapshot.outgoing).toEqual([]);
+ revisited.unmount();
+ anotherRoom.unmount();
+ anotherUser.unmount();
+ queryClient.clear();
+ });
+
+ it("does not recreate private outbox data when cleanup runs after logout clears the cache", async () => {
+ const { queryClient, render } = setup();
+ const hook = render(42);
+ await waitFor(() => expect(hook.result.current.snapshot.historyStatus).toBe("ready"));
+ act(() => events[0].onConnection("connected"));
+ await act(async () => {
+ await hook.result.current.sendText("로그아웃 시 제거할 원문");
+ });
+ expect(queryClient.getQueryData(chatKeys.outbox(7, 42))).toHaveLength(1);
+
+ act(() => {
+ useAuthStore.getState().clearAccessToken();
+ queryClient.removeQueries({ queryKey: chatKeys.all });
+ });
+ hook.rerender({ roomId: 42, userId: 7, enabled: false });
+ hook.unmount();
+ expect(queryClient.getQueryData(chatKeys.outbox(7, 42))).toBeUndefined();
+ queryClient.clear();
+ });
+});
diff --git a/apps/web/src/lib/chat/useChatSession.ts b/apps/web/src/lib/chat/useChatSession.ts
new file mode 100644
index 000000000..78bcdd3fb
--- /dev/null
+++ b/apps/web/src/lib/chat/useChatSession.ts
@@ -0,0 +1,67 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useSyncExternalStore } from "react";
+import { chatApi, chatKeys } from "@/apis/chat";
+import { imageUploadApi } from "@/apis/image-upload/api";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { getValidAccessToken } from "@/utils/axiosInstance";
+import { tokenParse } from "@/utils/jwtUtils";
+import { ChatSession } from "./ChatSession";
+import { createStompTransport } from "./stompTransport";
+import type { OutgoingMessage } from "./types";
+
+export const useChatSession = (roomId: number, userId: number, enabled: boolean) => {
+ const queryClient = useQueryClient();
+ const session = useMemo(
+ () =>
+ new ChatSession({
+ userId,
+ loadHistory: (page, signal) => chatApi.getChatHistories({ roomId, page, signal }),
+ markRead: (signal) => chatApi.putReadChatRoom(roomId, signal),
+ createTransport: (events) => createStompTransport({ roomId, getAccessToken: getValidAccessToken, ...events }),
+ initialOutgoing: queryClient.getQueryData(chatKeys.outbox(userId, roomId)),
+ saveOutgoing: (outgoing) => {
+ const auth = useAuthStore.getState();
+ if (auth.isAuthenticated && Number(tokenParse(auth.accessToken)?.sub) === userId) {
+ queryClient.setQueryData(chatKeys.outbox(userId, roomId), outgoing);
+ }
+ },
+ onMessagesChanged: () => {
+ void queryClient.invalidateQueries({ queryKey: chatKeys.rooms(userId) });
+ },
+ onRead: () => {
+ void queryClient.invalidateQueries({ queryKey: chatKeys.rooms(userId) });
+ },
+ }),
+ [queryClient, roomId, userId],
+ );
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
+
+ useEffect(
+ function manageRoomSession() {
+ // Unconfirmed sends survive route changes in memory and are cleared with the login cache.
+ queryClient.setQueryDefaults(chatKeys.outbox(userId, roomId), { gcTime: Infinity });
+ if (!enabled) return;
+ session.start();
+ const synchronizeVisibleRoom = () => {
+ if (document.visibilityState === "visible") void session.syncLatest();
+ };
+ window.addEventListener("focus", synchronizeVisibleRoom);
+ document.addEventListener("visibilitychange", synchronizeVisibleRoom);
+ return function stopRoomSession() {
+ window.removeEventListener("focus", synchronizeVisibleRoom);
+ document.removeEventListener("visibilitychange", synchronizeVisibleRoom);
+ session.stop();
+ };
+ },
+ [enabled, queryClient, roomId, session, userId],
+ );
+
+ return {
+ session,
+ snapshot,
+ sendText: async (content: string) => session.publish({ content: content.trim(), senderId: userId }),
+ sendImages: (files: File[]) => session.sendImages(files, imageUploadApi.postUploadChatImages),
+ };
+};
diff --git a/apps/web/src/lib/web-socket/useConnectWebSocket.ts b/apps/web/src/lib/web-socket/useConnectWebSocket.ts
deleted file mode 100644
index 0949dba09..000000000
--- a/apps/web/src/lib/web-socket/useConnectWebSocket.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { Client } from "@stomp/stompjs";
-import { type InfiniteData, useQueryClient } from "@tanstack/react-query";
-import type { MutableRefObject } from "react";
-import { useEffect, useState } from "react";
-import SockJS from "sockjs-client";
-import { type ChatHistoriesResponse, ChatQueryKeys } from "@/apis/chat/api";
-import { normalizeChatMessage, type RawChatMessage } from "@/apis/chat/normalize";
-
-import { type ChatMessage, ConnectionStatus } from "@/types/chat";
-import { isTokenExpired } from "@/utils/jwtUtils";
-import useAuthStore from "../zustand/useAuthStore";
-
-interface UseConnectWebSocketProps {
- roomId: number | null;
- clientRef: MutableRefObject;
-}
-
-interface UseConnectWebSocketReturn {
- connectionStatus: ConnectionStatus;
- submittedMessages: ChatMessage[];
- setSubmittedMessages: React.Dispatch>;
-}
-
-const NEXT_PUBLIC_API_SERVER_URL = process.env.NEXT_PUBLIC_API_SERVER_URL;
-
-const getMessageCacheKey = (message: ChatMessage) => {
- if (message.id !== 0) return `id:${message.id}`;
-
- const attachmentKey = message.attachments
- .map((attachment) => `${attachment.isImage ? "image" : "file"}:${attachment.url}:${attachment.createdAt}`)
- .join(",");
-
- return `fallback:${message.senderId}:${message.createdAt}:${message.content}:${attachmentKey}`;
-};
-
-const appendMessageToChatHistories = (
- oldData: InfiniteData | undefined,
- message: ChatMessage,
-) => {
- if (!oldData || oldData.pages.length === 0) return oldData;
-
- const messageKey = getMessageCacheKey(message);
- const hasMessage = oldData.pages.some((page) =>
- page.content.some((cachedMessage) => getMessageCacheKey(cachedMessage) === messageKey),
- );
-
- if (hasMessage) return oldData;
-
- return {
- ...oldData,
- pages: oldData.pages.map((page, index) =>
- index === 0
- ? {
- ...page,
- content: [...page.content, message],
- }
- : page,
- ),
- };
-};
-
-const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): UseConnectWebSocketReturn => {
- // Hook 내부에서 연결 상태를 직접 관리
- const [connectionStatus, setConnectionStatus] = useState(ConnectionStatus.Disconnected);
- const [submittedMessages, setSubmittedMessages] = useState([]);
- const queryClient = useQueryClient();
- const accessToken = useAuthStore((state) => state.accessToken);
- const isInitialized = useAuthStore((state) => state.isInitialized);
- const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken));
-
- useEffect(() => {
- if (!roomId) {
- setConnectionStatus(ConnectionStatus.Disconnected);
- return;
- }
-
- if (!isInitialized || !hasValidAccessToken) {
- setConnectionStatus(ConnectionStatus.Pending);
- return;
- }
-
- const connect = async () => {
- setConnectionStatus(ConnectionStatus.Pending); // 연결 시도 중 상태로 설정
- const token = accessToken;
-
- try {
- const client = new Client({
- webSocketFactory: () => new SockJS(`${NEXT_PUBLIC_API_SERVER_URL}/connect?token=${token}`),
- // ...existing code...
- heartbeatIncoming: 50000,
- heartbeatOutgoing: 50000,
- reconnectDelay: 50000,
- });
-
- client.onConnect = () => {
- setConnectionStatus(ConnectionStatus.Connected);
- client.subscribe(`/topic/chat/${roomId}`, (message) => {
- try {
- const receivedMessage = normalizeChatMessage(JSON.parse(message.body) as RawChatMessage);
-
- if (!receivedMessage.createdAt || Number.isNaN(new Date(receivedMessage.createdAt).getTime())) {
- receivedMessage.createdAt = new Date().toISOString();
- }
-
- queryClient.setQueryData>(
- [ChatQueryKeys.chatHistories, roomId],
- (oldData) => appendMessageToChatHistories(oldData, receivedMessage),
- );
- queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms], refetchType: "none" });
- setSubmittedMessages((prev) => [...prev, receivedMessage]);
- } catch (error) {}
- });
- };
-
- client.onStompError = (frame) => {
- setConnectionStatus(ConnectionStatus.Error);
- };
-
- client.onDisconnect = () => {
- setConnectionStatus(ConnectionStatus.Disconnected);
- };
-
- client.activate();
- clientRef.current = client;
- } catch (error) {
- setConnectionStatus(ConnectionStatus.Error);
- }
- };
-
- connect();
-
- // Clean-up 함수
- return () => {
- if (clientRef.current?.active) {
- clientRef.current.deactivate();
- }
- clientRef.current = null;
- };
- }, [roomId, clientRef, accessToken, hasValidAccessToken, isInitialized, queryClient]);
-
- // 관리하는 connectionStatus를 반환
- return { connectionStatus, submittedMessages, setSubmittedMessages };
-};
-
-export default useConnectWebSocket;
diff --git a/apps/web/src/lib/zustand/useAuthStore.ts b/apps/web/src/lib/zustand/useAuthStore.ts
index b07a63375..46d4fd2d3 100644
--- a/apps/web/src/lib/zustand/useAuthStore.ts
+++ b/apps/web/src/lib/zustand/useAuthStore.ts
@@ -17,7 +17,7 @@ const parseHomeUniversityId = (homeUniversity: string | undefined): number | nul
return Number.isInteger(homeUniversityId) && homeUniversityId > 0 ? homeUniversityId : null;
};
-type RefreshStatus = "idle" | "refreshing" | "success" | "failed";
+type RefreshStatus = "idle" | "refreshing" | "success" | "retryable" | "failed";
type ClientRole = UserRole.MENTOR | UserRole.MENTEE;
const parseAuthToken = (token: string | null) => {
@@ -48,7 +48,23 @@ const resolveClientRole = (serverRole: UserRole | null, currentClientRole: Clien
return null;
};
+const authenticatedTokenState = (token: string, clientRole: ClientRole | null) => {
+ const { serverRole, homeUniversityId } = parseAuthToken(token);
+ return {
+ accessToken: token,
+ serverRole,
+ clientRole: resolveClientRole(serverRole, clientRole),
+ homeUniversityId,
+ isAuthenticated: true,
+ isLoading: false,
+ isInitialized: true,
+ refreshStatus: "success" as const,
+ shouldAttemptRefresh: true,
+ };
+};
+
interface AuthState {
+ sessionVersion: number;
accessToken: string | null;
serverRole: UserRole | null;
clientRole: ClientRole | null;
@@ -59,6 +75,7 @@ interface AuthState {
refreshStatus: RefreshStatus;
shouldAttemptRefresh: boolean;
setAccessToken: (token: string) => void;
+ setRefreshedAccessToken: (token: string, expectedSessionVersion: number) => void;
clearAccessToken: () => void;
setClientRole: (role: ClientRole) => void;
setLoading: (loading: boolean) => void;
@@ -69,6 +86,7 @@ interface AuthState {
const useAuthStore = create()(
persist(
(set) => ({
+ sessionVersion: 0,
accessToken: null,
serverRole: null,
clientRole: null,
@@ -80,25 +98,21 @@ const useAuthStore = create()(
shouldAttemptRefresh: false,
setAccessToken: (token) => {
- set((state) => {
- const { serverRole, homeUniversityId } = parseAuthToken(token);
-
- return {
- accessToken: token,
- serverRole,
- clientRole: resolveClientRole(serverRole, state.clientRole),
- homeUniversityId,
- isAuthenticated: true,
- isLoading: false,
- isInitialized: true,
- refreshStatus: "success",
- shouldAttemptRefresh: true,
- };
- });
+ set((state) => ({
+ ...authenticatedTokenState(token, state.clientRole),
+ sessionVersion: state.sessionVersion + 1,
+ }));
+ },
+
+ setRefreshedAccessToken: (token, expectedSessionVersion) => {
+ set((state) =>
+ state.sessionVersion === expectedSessionVersion ? authenticatedTokenState(token, state.clientRole) : {},
+ );
},
clearAccessToken: () => {
- set({
+ set((state) => ({
+ sessionVersion: state.sessionVersion + 1,
accessToken: null,
serverRole: null,
clientRole: null,
@@ -108,7 +122,7 @@ const useAuthStore = create()(
isInitialized: true,
refreshStatus: "idle",
shouldAttemptRefresh: false,
- });
+ }));
},
setClientRole: (role) => {
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
new file mode 100644
index 000000000..f141e23f6
--- /dev/null
+++ b/apps/web/src/test/setup.ts
@@ -0,0 +1,11 @@
+import { cleanup } from "@testing-library/react/pure";
+import { afterEach, vi } from "vitest";
+
+if (typeof document !== "undefined") {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+}
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
diff --git a/apps/web/src/utils/axiosInstance.chat.test.ts b/apps/web/src/utils/axiosInstance.chat.test.ts
new file mode 100644
index 000000000..b6edcbf93
--- /dev/null
+++ b/apps/web/src/utils/axiosInstance.chat.test.ts
@@ -0,0 +1,207 @@
+// @vitest-environment jsdom
+import { AxiosError, type InternalAxiosRequestConfig } from "axios";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import queryClient from "@/lib/react-query/queryClient";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { axiosInstance, getValidAccessToken } from "./axiosInstance";
+
+const { reissueToken } = vi.hoisted(() => ({ reissueToken: vi.fn<() => Promise>() }));
+vi.mock("@/apis/Auth/server", () => ({ postReissueToken: reissueToken }));
+vi.mock("@/lib/react-query/queryClient", () => ({ default: { removeQueries: vi.fn() } }));
+
+function token(expiration: number) {
+ return `header.${btoa(JSON.stringify({ sub: 7, role: "MENTOR", exp: expiration }))}.signature`;
+}
+
+beforeEach(() => {
+ reissueToken.mockReset();
+ useAuthStore.getState().setAccessToken(token(Math.floor(Date.now() / 1000) + 3600));
+});
+
+afterEach(() => {
+ useAuthStore.getState().clearAccessToken();
+});
+
+describe("chat socket and HTTP token rotation", () => {
+ it("refreshes at the exact expiration second instead of returning expired credentials", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-11-04T00:00:00Z"));
+ useAuthStore.getState().setAccessToken(token(Math.floor(Date.now() / 1000)));
+ const refreshed = token(Math.floor(Date.now() / 1000) + 3600);
+ reissueToken.mockResolvedValue(refreshed);
+ await expect(getValidAccessToken()).resolves.toBe(refreshed);
+ expect(reissueToken).toHaveBeenCalledOnce();
+ });
+
+ it.each([
+ "socket-first",
+ "http-first",
+ ])("shares one refresh and preserves the authenticated identity (%s)", async (order) => {
+ let resolveRefresh!: (value: string) => void;
+ const pendingRefresh = new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+ reissueToken.mockImplementation(async () => {
+ const refreshed = await pendingRefresh;
+ return refreshed;
+ });
+ const expiredToken = token(Math.floor(Date.now() / 1000) - 10);
+ useAuthStore.setState({ accessToken: expiredToken, refreshStatus: "success", isAuthenticated: true });
+ const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => ({
+ data: { chatRooms: [] },
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ config,
+ }));
+
+ const socketToken = order === "socket-first" ? getValidAccessToken() : undefined;
+ const firstRequest = axiosInstance.get("/chats/rooms", { adapter });
+ const secondRequest = axiosInstance.get("/chats/rooms/42/partner", { adapter });
+ await Promise.resolve();
+ const waitingSocketToken = socketToken ?? getValidAccessToken();
+ for (let turn = 0; turn < 5; turn += 1) await Promise.resolve();
+
+ expect(reissueToken).toHaveBeenCalledOnce();
+ expect(adapter).not.toHaveBeenCalled();
+ expect(useAuthStore.getState()).toMatchObject({
+ accessToken: expiredToken,
+ isAuthenticated: true,
+ refreshStatus: "refreshing",
+ });
+
+ const refreshedToken = token(Math.floor(Date.now() / 1000) + 7200);
+ resolveRefresh(refreshedToken);
+ await expect(waitingSocketToken).resolves.toBe(refreshedToken);
+ await Promise.all([firstRequest, secondRequest]);
+ expect(adapter).toHaveBeenCalledTimes(2);
+ for (const [config] of adapter.mock.calls) {
+ expect(config.headers.Authorization).toBe(`Bearer ${refreshedToken}`);
+ }
+ expect(useAuthStore.getState()).toMatchObject({
+ isAuthenticated: true,
+ refreshStatus: "success",
+ isLoading: false,
+ });
+ });
+});
+
+it("waits for a 401-triggered refresh before returning a nominally valid socket credential", async () => {
+ let complete!: () => void;
+ const refreshed = token(Math.floor(Date.now() / 1000) + 7200);
+ reissueToken.mockImplementation(async () => {
+ await new Promise((resolve) => {
+ complete = resolve;
+ });
+ return refreshed;
+ });
+ const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => {
+ if (!config.headers.Authorization?.toString().includes(refreshed)) {
+ throw new AxiosError("expired on server", "ERR_BAD_REQUEST", config, undefined, {
+ config,
+ status: 401,
+ statusText: "Unauthorized",
+ headers: {},
+ data: {},
+ });
+ }
+ return { config, status: 200, statusText: "OK", headers: {}, data: {} };
+ });
+ const request = axiosInstance.get("/chats/rooms", { adapter });
+ await vi.waitFor(() => expect(reissueToken).toHaveBeenCalledOnce());
+ let socketResolved = false;
+ const socket = getValidAccessToken().then((value) => {
+ socketResolved = true;
+ return value;
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+ const resolvedBeforeRefresh = socketResolved;
+ complete();
+ const socketCredential = await socket;
+ await request;
+ expect(resolvedBeforeRefresh).toBe(false);
+ expect(socketCredential).toBe(refreshed);
+});
+
+it.each([
+ undefined,
+ 503,
+])("keeps identity and cache after a transient refresh failure (%s) and retries", async (status) => {
+ const expiredToken = token(Math.floor(Date.now() / 1000) - 10);
+ useAuthStore.setState({ accessToken: expiredToken, isAuthenticated: true });
+ const failure = new AxiosError(
+ "temporarily unavailable",
+ "ERR_NETWORK",
+ undefined,
+ undefined,
+ status
+ ? { config: {} as InternalAxiosRequestConfig, status, statusText: "Unavailable", headers: {}, data: {} }
+ : undefined,
+ );
+ reissueToken.mockRejectedValueOnce(failure);
+ vi.mocked(queryClient.removeQueries).mockClear();
+ await expect(getValidAccessToken()).rejects.toBe(failure);
+ expect(useAuthStore.getState()).toMatchObject({
+ accessToken: expiredToken,
+ isAuthenticated: true,
+ refreshStatus: "retryable",
+ isLoading: false,
+ });
+ expect(queryClient.removeQueries).not.toHaveBeenCalled();
+ const renewed = token(Math.floor(Date.now() / 1000) + 7200);
+ reissueToken.mockImplementationOnce(async () => {
+ return renewed;
+ });
+ await expect(getValidAccessToken()).resolves.toBe(renewed);
+});
+
+it("retries refresh rather than reusing a server-rejected token after a 503", async () => {
+ const oldToken = useAuthStore.getState().accessToken;
+ const failure = new AxiosError("Unavailable", "ERR_BAD_RESPONSE", undefined, undefined, {
+ config: {} as InternalAxiosRequestConfig,
+ status: 503,
+ statusText: "Unavailable",
+ headers: {},
+ data: {},
+ });
+ reissueToken.mockRejectedValueOnce(failure);
+ const adapter = async (config: InternalAxiosRequestConfig) => {
+ throw new AxiosError("Unauthorized", "ERR_BAD_REQUEST", config, undefined, {
+ config,
+ status: 401,
+ statusText: "Unauthorized",
+ headers: {},
+ data: {},
+ });
+ };
+ await expect(axiosInstance.get("/chats/rooms", { adapter })).rejects.toBe(failure);
+ expect(useAuthStore.getState()).toMatchObject({
+ accessToken: oldToken,
+ isAuthenticated: true,
+ refreshStatus: "retryable",
+ });
+ const renewed = token(Math.floor(Date.now() / 1000) + 7200);
+ reissueToken.mockImplementationOnce(async () => {
+ return renewed;
+ });
+ await expect(getValidAccessToken()).resolves.toBe(renewed);
+ expect(reissueToken).toHaveBeenCalledTimes(2);
+});
+
+it.each([401, 403])("clears identity and chat cache on refresh denial (%s)", async (status) => {
+ useAuthStore.setState({ accessToken: token(Math.floor(Date.now() / 1000) - 10) });
+ reissueToken.mockRejectedValueOnce(
+ new AxiosError("Denied", "ERR_BAD_REQUEST", undefined, undefined, {
+ config: {} as InternalAxiosRequestConfig,
+ status,
+ statusText: "Denied",
+ headers: {},
+ data: {},
+ }),
+ );
+ vi.mocked(queryClient.removeQueries).mockClear();
+ await expect(getValidAccessToken()).resolves.toBeNull();
+ expect(useAuthStore.getState()).toMatchObject({ accessToken: null, isAuthenticated: false, refreshStatus: "failed" });
+ expect(queryClient.removeQueries).toHaveBeenCalledWith({ queryKey: ["chat"] });
+});
diff --git a/apps/web/src/utils/axiosInstance.session.test.ts b/apps/web/src/utils/axiosInstance.session.test.ts
new file mode 100644
index 000000000..6b4dc74de
--- /dev/null
+++ b/apps/web/src/utils/axiosInstance.session.test.ts
@@ -0,0 +1,99 @@
+// @vitest-environment jsdom
+import { AxiosError, type InternalAxiosRequestConfig } from "axios";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import queryClient from "@/lib/react-query/queryClient";
+import useAuthStore from "@/lib/zustand/useAuthStore";
+import { AuthenticationChangedError, axiosInstance, getValidAccessToken, publicAxiosInstance } from "./axiosInstance";
+
+vi.mock("@/lib/react-query/queryClient", () => ({ default: { removeQueries: vi.fn() } }));
+const originalAdapter = publicAxiosInstance.defaults.adapter;
+function token(user: number, expired = false) {
+ return `header.${btoa(JSON.stringify({ sub: user, role: "MENTOR", exp: Math.floor(Date.now() / 1000) + (expired ? -10 : 3600) }))}.signature`;
+}
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((accept, deny) => {
+ resolve = accept;
+ reject = deny;
+ });
+ return { promise, resolve, reject };
+}
+beforeEach(() => {
+ useAuthStore.getState().setAccessToken(token(7, true));
+ vi.mocked(queryClient.removeQueries).mockClear();
+});
+afterEach(() => {
+ publicAxiosInstance.defaults.adapter = originalAdapter;
+ useAuthStore.getState().clearAccessToken();
+});
+
+it.each(["logout", "other-account"])("does not commit a late refresh token after %s", async (change) => {
+ const response = deferred();
+ publicAxiosInstance.defaults.adapter = async (config) => ({
+ config,
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ data: { accessToken: await response.promise },
+ });
+ const pending = getValidAccessToken();
+ // Attach the rejection handler before resolving the network response.
+ const rejected = expect(pending).rejects.toBeInstanceOf(AuthenticationChangedError);
+ await vi.waitFor(() => expect(useAuthStore.getState().refreshStatus).toBe("refreshing"));
+ if (change === "logout") useAuthStore.getState().clearAccessToken();
+ else useAuthStore.getState().setAccessToken(token(8));
+ const expected = useAuthStore.getState();
+ response.resolve(token(7));
+ await rejected;
+ expect(useAuthStore.getState()).toBe(expected);
+ expect(queryClient.removeQueries).not.toHaveBeenCalled();
+});
+
+it.each([401, 403, 503])("does not clear a newer account after old refresh error %s", async (status) => {
+ const response = deferred();
+ publicAxiosInstance.defaults.adapter = async (config) => {
+ await response.promise;
+ throw new AxiosError("Refresh failed", "ERR_BAD_RESPONSE", config, undefined, {
+ config,
+ status,
+ statusText: "Failed",
+ headers: {},
+ data: {},
+ });
+ };
+ const pending = getValidAccessToken();
+ const rejected = expect(pending).rejects.toBeInstanceOf(AuthenticationChangedError);
+ await vi.waitFor(() => expect(useAuthStore.getState().refreshStatus).toBe("refreshing"));
+ useAuthStore.getState().setAccessToken(token(8));
+ const expected = useAuthStore.getState();
+ response.resolve();
+ await rejected;
+ expect(useAuthStore.getState()).toBe(expected);
+ expect(queryClient.removeQueries).not.toHaveBeenCalled();
+});
+
+it("never sends an older user's waiting HTTP request with a new user's token", async () => {
+ const response = deferred();
+ publicAxiosInstance.defaults.adapter = async (config) => ({
+ config,
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ data: { accessToken: await response.promise },
+ });
+ const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => ({
+ config,
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ data: {},
+ }));
+ const request = axiosInstance.get("/chats/rooms", { adapter });
+ const rejected = expect(request).rejects.toBeInstanceOf(AuthenticationChangedError);
+ await vi.waitFor(() => expect(useAuthStore.getState().refreshStatus).toBe("refreshing"));
+ useAuthStore.getState().setAccessToken(token(8));
+ response.resolve(token(7));
+ await rejected;
+ expect(adapter).not.toHaveBeenCalled();
+});
diff --git a/apps/web/src/utils/axiosInstance.ts b/apps/web/src/utils/axiosInstance.ts
index 1c1629e74..8e60b9637 100644
--- a/apps/web/src/utils/axiosInstance.ts
+++ b/apps/web/src/utils/axiosInstance.ts
@@ -1,4 +1,4 @@
-import axios, { type AxiosError, type AxiosInstance } from "axios";
+import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
import { postReissueToken } from "@/apis/Auth/server";
import { QueryKeys } from "@/apis/queryKeys";
import queryClient from "@/lib/react-query/queryClient";
@@ -7,7 +7,15 @@ import useAuthStore from "@/lib/zustand/useAuthStore";
import { isTokenExpired } from "@/utils/jwtUtils";
// --- 글로벌 변수 ---
-let reissuePromise: Promise | null = null;
+let refreshRequest: { sessionVersion: number; promise: Promise } | null = null;
+type AuthRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean; _sessionVersion?: number };
+
+export class AuthenticationChangedError extends Error {
+ constructor() {
+ super("Authentication changed while the request was pending");
+ this.name = "AuthenticationChangedError";
+ }
+}
// --- 커스텀 에러 클래스 ---
export class AuthenticationRequiredError extends Error {
@@ -23,6 +31,7 @@ let isRedirecting = false;
const clearAuthState = () => {
useAuthStore.getState().clearAccessToken();
queryClient.removeQueries({ queryKey: [QueryKeys.MyPage.profile] });
+ queryClient.removeQueries({ queryKey: ["chat"] });
};
// --- 유틸리티 함수 ---
@@ -43,32 +52,53 @@ const redirectToLogin = (message: string) => {
export const convertToBearer = (token: string) => `Bearer ${token}`;
-const tryReissueAccessToken = async (): Promise => {
- if (reissuePromise) {
- await reissuePromise;
- return useAuthStore.getState().accessToken;
- }
-
- const { setLoading, setInitialized, setRefreshStatus } = useAuthStore.getState();
-
- reissuePromise = (async () => {
- setRefreshStatus("refreshing");
- setLoading(true);
+// The raw API only returns credentials. This owner alone may commit or clear the active session.
+export const refreshAccessToken = (): Promise => {
+ const auth = useAuthStore.getState();
+ if (refreshRequest?.sessionVersion === auth.sessionVersion) return refreshRequest.promise;
+ const request = { sessionVersion: auth.sessionVersion, promise: Promise.resolve(null) };
+ let ownedVersion = auth.sessionVersion;
+ const isCurrentSession = () => useAuthStore.getState().sessionVersion === ownedVersion;
+ request.promise = (async () => {
+ auth.setRefreshStatus("refreshing");
+ auth.setLoading(true);
try {
- await postReissueToken();
- setRefreshStatus("success");
- } catch {
- clearAuthState();
- setRefreshStatus("failed");
+ const token = await postReissueToken();
+ if (!isCurrentSession()) throw new AuthenticationChangedError();
+ useAuthStore.getState().setRefreshedAccessToken(token, ownedVersion);
+ return token;
+ } catch (error) {
+ if (!isCurrentSession()) throw new AuthenticationChangedError();
+ const status = (error as AxiosError).response?.status;
+ if (status === 401 || status === 403) {
+ clearAuthState();
+ ownedVersion = useAuthStore.getState().sessionVersion;
+ useAuthStore.getState().setRefreshStatus("failed");
+ return null;
+ }
+ useAuthStore.getState().setRefreshStatus("retryable");
+ throw error;
} finally {
- setLoading(false);
- setInitialized(true);
- reissuePromise = null;
+ if (isCurrentSession()) {
+ useAuthStore.getState().setLoading(false);
+ useAuthStore.getState().setInitialized(true);
+ }
+ if (refreshRequest === request) refreshRequest = null;
}
})();
+ refreshRequest = request;
+ return request.promise;
+};
- await reissuePromise;
- return useAuthStore.getState().accessToken;
+// HTTP requests, authentication initialization and socket reconnects share one refresh request.
+export const getValidAccessToken = async (): Promise => {
+ const auth = useAuthStore.getState();
+ if (refreshRequest?.sessionVersion === auth.sessionVersion) return refreshRequest.promise;
+ const { accessToken: token, refreshStatus } = auth;
+ if (refreshStatus === "failed") return null;
+ // A token rejected by the server remains unusable even if its exp has not passed.
+ if (token && !isTokenExpired(token) && refreshStatus !== "retryable") return token;
+ return refreshAccessToken();
};
// --- Axios 인스턴스 ---
@@ -89,46 +119,20 @@ export const axiosInstance: AxiosInstance = axios.create({
// 1. 요청 인터셉터 (Request Interceptor)
axiosInstance.interceptors.request.use(
async (config) => {
- const { accessToken, clearAccessToken, refreshStatus } = useAuthStore.getState();
-
- // 만료된 access token은 즉시 제거하고 refresh 재발급 경로를 타게 한다.
- if (accessToken && isTokenExpired(accessToken)) {
- clearAccessToken();
- }
-
- const validAccessToken = useAuthStore.getState().accessToken;
-
- // 토큰이 있으면 헤더에 추가하고 진행
- if (validAccessToken) {
- config.headers.Authorization = convertToBearer(validAccessToken);
+ const sessionVersion = useAuthStore.getState().sessionVersion;
+ const token = await getValidAccessToken();
+ const currentAuth = useAuthStore.getState();
+ if (
+ sessionVersion !== currentAuth.sessionVersion &&
+ (token !== null || currentAuth.refreshStatus !== "failed" || currentAuth.isAuthenticated)
+ )
+ throw new AuthenticationChangedError();
+ (config as AuthRequestConfig)._sessionVersion = sessionVersion;
+ if (token) {
+ config.headers.Authorization = convertToBearer(token);
return config;
}
-
- if (refreshStatus !== "failed") {
- try {
- await tryReissueAccessToken();
-
- // reissue 완료 후 업데이트된 토큰으로 헤더 설정
- const updatedAccessToken = useAuthStore.getState().accessToken;
- if (updatedAccessToken) {
- config.headers.Authorization = convertToBearer(updatedAccessToken);
- }
- } catch {
- // 에러 발생 시에도 상태 정리는 promise 내부의 finally에서 처리됨
- }
-
- // reissue 후 토큰이 있으면 헤더에 추가
- const finalAccessToken = useAuthStore.getState().accessToken;
- if (finalAccessToken) {
- config.headers.Authorization = convertToBearer(finalAccessToken);
- return config;
- }
- }
-
- const { isInitialized: currentInitialized, accessToken: currentAccessToken } = useAuthStore.getState();
-
- // 초기화는 되었지만 토큰이 없는 경우 로그인 필요
- if (currentInitialized && !currentAccessToken) {
+ if (useAuthStore.getState().isInitialized) {
redirectToLogin("로그인이 필요합니다. 다시 로그인해주세요.");
return Promise.reject(new AuthenticationRequiredError());
}
@@ -147,21 +151,25 @@ axiosInstance.interceptors.response.use(
const status = error.response?.status;
if (status === 401) {
- const originalRequest = error.config as (typeof error.config & { _retry?: boolean }) | undefined;
+ const originalRequest = error.config as AuthRequestConfig | undefined;
+ if (originalRequest?._sessionVersion !== useAuthStore.getState().sessionVersion) {
+ return Promise.reject(new AuthenticationChangedError());
+ }
if (originalRequest && !originalRequest._retry && useAuthStore.getState().refreshStatus !== "failed") {
originalRequest._retry = true;
try {
- const reissuedAccessToken = await tryReissueAccessToken();
+ const reissuedAccessToken = await refreshAccessToken();
if (reissuedAccessToken) {
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken);
return axiosInstance(originalRequest);
}
- } catch {
- // 재발급 실패 시 아래 로그인 리다이렉트로 처리
+ } catch (refreshError) {
+ // Temporary failures keep the local session and let the caller retry.
+ return Promise.reject(refreshError);
}
}
diff --git a/apps/web/src/utils/jwtUtils.ts b/apps/web/src/utils/jwtUtils.ts
index 9c0a35eec..d2f4a2bfe 100644
--- a/apps/web/src/utils/jwtUtils.ts
+++ b/apps/web/src/utils/jwtUtils.ts
@@ -30,7 +30,7 @@ export const isTokenExpired = (token: string | null): boolean => {
}
const currentTime = Math.floor(Date.now() / 1000);
- return payload.exp < currentTime;
+ return payload.exp <= currentTime;
};
export const tokenParse = (token: string | null): JwtPayload | null => {
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 000000000..ea2e4b383
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath, URL } from "node:url";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@/public": fileURLToPath(new URL("./public", import.meta.url)),
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ oxc: {
+ jsx: { runtime: "automatic" },
+ },
+ test: {
+ environment: "node",
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ setupFiles: ["./src/test/setup.ts"],
+ restoreMocks: true,
+ unstubGlobals: true,
+ },
+});
diff --git a/apps/web/vitest.storybook.config.ts b/apps/web/vitest.storybook.config.ts
new file mode 100644
index 000000000..6781b9970
--- /dev/null
+++ b/apps/web/vitest.storybook.config.ts
@@ -0,0 +1,18 @@
+import { fileURLToPath } from "node:url";
+import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
+import { playwright } from "@vitest/browser-playwright";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [storybookTest({ configDir: fileURLToPath(new URL("./.storybook", import.meta.url)) })],
+ test: {
+ name: "storybook",
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright(),
+ instances: [{ browser: "chromium" }],
+ screenshotDirectory: "./test-results/storybook",
+ },
+ },
+});
diff --git a/docs/chat-system.md b/docs/chat-system.md
new file mode 100644
index 000000000..a442dc163
--- /dev/null
+++ b/docs/chat-system.md
@@ -0,0 +1,91 @@
+# 채팅 시스템
+
+채팅은 기존 서버의 REST API와 STOMP 연결을 사용합니다. REST API는 대화 기록을 조회하고 읽음 상태를 변경합니다. STOMP는 연결이 열린 동안 새 메시지를 주고받는 통신 규칙입니다. 서버의 요청 경로와 데이터 형식은 유지합니다.
+
+## 메시지를 관리하는 곳
+
+`apps/web/src/lib/chat/ChatSession.ts`가 열린 채팅방의 대화 기록을 관리합니다. 기록 조회 응답과 실시간 수신은 모두 이곳에서 합칩니다. 화면이 별도의 메시지 배열을 만들어 서버 응답으로 덮어쓰지 않습니다.
+
+메시지에 서버가 부여한 양수 ID가 있으면 같은 ID를 한 번만 표시합니다. ID가 없으면 작성자와 작성 시각과 본문 및 첨부 정보를 비교합니다. ID 없는 메시지의 해시값만 비교하면 다른 메시지를 하나로 합칠 수 있으므로 전체 정보를 비교합니다.
+
+`stompTransport.ts`는 연결과 구독 및 발행을 담당합니다. 화면의 스크롤과 입력값은 알지 못합니다. 연결을 다시 열 때마다 공통 인증 함수에서 최신 토큰을 가져옵니다. HTTP 요청과 인증 초기화도 같은 토큰 갱신 요청을 공유합니다.
+
+`useChatSession.ts`는 방이 열릴 때 세션을 시작하고 닫힐 때 정리합니다. `useChatScroll.ts`는 화면 위치만 관리합니다. 입력창은 작성 중인 문장과 선택한 이미지를 관리합니다.
+
+## 연결이 끊겼다가 돌아올 때
+
+연결이 끊기면 작성 중인 문장과 선택한 이미지를 유지합니다. 전송 버튼은 연결이 돌아올 때까지 잠그지만 문장은 계속 작성할 수 있습니다.
+
+다시 연결하면 최신 기록부터 조회합니다. 마지막으로 조회를 마친 기록과 겹칠 때까지 다음 페이지도 확인합니다. 연결이 끊긴 동안 한 페이지보다 많은 메시지가 쌓여도 최신 한 페이지만 읽고 끝내지 않습니다. 실시간 수신만으로는 중간 기록이 이어졌다고 판단하지 않습니다.
+
+사용자가 이전 대화를 여는 페이지 번호는 이 복구 조회와 별도로 관리합니다. 기록을 합칠 때 중복 메시지를 제거합니다. 이전 기록을 추가하는 동안 화면에서 보던 메시지의 위치를 유지합니다.
+
+## 전송 확인과 실패
+
+소켓의 발행 함수가 반환되어도 서버 저장 완료를 뜻하지는 않습니다. 발행한 메시지는 먼저 전송 대기 항목에 보관합니다. 서버 ID가 있는 새 자기 메시지를 수신하면 같은 본문 또는 이미지 URL에 해당하는 대기 항목을 순서대로 정리합니다. 중복 수신은 다음 대기 항목을 정리하는 근거로 사용하지 않습니다.
+
+15초 안에 해당 수신을 확인하지 못하면 전송 여부를 확인해야 하는 상태로 표시합니다. 원문과 이미지 URL은 유지합니다. 같은 로그인 상태에서 다른 방에 갔다 돌아와도 확인하지 못한 항목을 다시 볼 수 있습니다. 이 보관은 메모리에서 이루어집니다. 페이지를 새로고침하거나 로그아웃하면 없어집니다.
+
+메시지를 자동으로 다시 보내지는 않습니다. 현재 서버 요청에는 클라이언트가 만든 메시지 ID가 없어서 재전송한 요청과 새 요청을 서버가 구별한다는 보장이 없습니다. 확인되지 않은 항목에서는 기록을 다시 확인하거나 대기 표시만 지울 수 있습니다. 대기 표시를 지워도 서버의 메시지는 삭제되지 않습니다.
+
+동일 계정의 다른 기기에서 같은 문장을 보내면 수신과 대기 항목의 정확한 대응은 보장할 수 없습니다. 서버가 클라이언트 메시지 ID를 받아 그대로 반환하도록 확장하면 이 제한을 해소할 수 있습니다. 현재 화면은 별도의 서버 저장 완료 표시를 제공하지 않습니다.
+
+발행하기 전에 연결이 끊겼거나 이미지 업로드가 실패하면 입력창을 비우지 않습니다. 요청 진행 중에는 입력과 선택을 잠가 이전 요청의 완료가 새 입력을 지우지 않도록 합니다. 한글을 조합하는 중에는 Enter로 전송하지 않습니다.
+
+## 이미지와 파일
+
+선택한 이미지는 업로드 전에 미리 볼 수 있습니다. 미리보기용 임시 URL은 파일을 제거하거나 화면을 닫을 때 해제합니다. 이미지를 업로드한 뒤에도 원래 방과 사용자 세션이 유효한지 확인하고 발행합니다. 이전 방의 늦은 업로드 결과가 다른 방에서 발행되지 않습니다.
+
+수신 이미지를 불러오지 못하면 최대 세 번 시도한 뒤 오류와 수동 재시도를 표시합니다. 일반 파일의 수신 표시와 다운로드는 유지합니다. 일반 파일을 보내는 서버 계약은 이 저장소에서 확인되지 않아 보내기 기능은 제공하지 않습니다. 잘못된 게시글 신고를 보내던 채팅 신고 버튼과 작동하지 않던 알림 설정도 제공하지 않습니다.
+
+## 읽음과 사용자 격리
+
+화면이 표시 중이고 대화의 하단을 보고 있을 때 읽음 요청을 보냅니다. 연속 수신은 요청 하나로 모읍니다. 읽음 요청 중 새 메시지가 들어오면 완료 후 다시 반영합니다. 실패하면 재시도 안내를 표시합니다.
+
+방 목록과 상대 정보 및 미확인 전송 항목의 조회 키에는 로그인한 사용자 ID가 포함됩니다. 로그아웃이나 인증 실패 때 채팅 캐시를 제거합니다. 정리 중인 이전 화면이 제거된 캐시에 원문을 다시 쓰지 못하도록 현재 사용자도 확인합니다.
+
+방이 바뀌거나 화면이 닫히면 진행 중인 요청을 취소합니다. 취소를 지원하지 않는 작업도 시작한 세대를 비교해 늦은 결과를 무시합니다. 기록 조회와 읽음 요청에서 403 또는 404를 받으면 연결을 종료하고 대화를 숨기며 전송을 막습니다. 상대 정보 조회에서 같은 응답을 받은 경우에도 대화를 숨깁니다.
+
+## 검증
+
+Node.js 22와 pnpm 9에서 다음 명령을 실행합니다. `ci:check`에는 채팅 테스트가 포함됩니다.
+
+```sh
+pnpm --filter @solid-connect/web run test:chat
+pnpm --filter @solid-connect/web run typecheck
+pnpm --filter @solid-connect/web run ci:check
+```
+
+테스트는 실제 서버 없이 제어 가능한 연결과 응답을 사용합니다. 초기 조회와 수신의 순서 역전과 여러 페이지의 재연결 복구 및 중복 수신을 확인합니다. 전송 실패와 늦은 업로드 및 방 전환과 읽음 요청을 확인합니다. 로그인 갱신 중 입력 유지와 로그아웃 뒤 데이터 제거도 확인합니다.
+
+브라우저 검증은 실제 운영 메시지를 만들지 않는 로컬 서버에서 수행합니다. 실제 서비스의 두 계정 간 전달과 서버 저장 및 운영 환경의 읽음 집계는 별도 연동 검증이 필요합니다.
+
+### 2026년 10월 27일 로컬 확인 결과
+
+Node.js 22.20.0에서 웹 앱의 린트와 타입 검사 및 회귀 테스트 101개가 통과했습니다. 로컬 가짜 API를 사용하는 프로덕션 빌드도 통과했습니다. 브라우저는 로컬 가짜 서버와 실제 SockJS 클라이언트를 연결해 확인했습니다.
+
+| 확인한 흐름 | 관찰한 결과 |
+| --- | --- |
+| 목록에서 방에 들어가 텍스트 전송 | 수신한 메시지가 한 번 표시되고 입력창이 비워짐 |
+| 연결 중단 후 메시지 45개 누적 | 기존 기록과 합친 65개의 ID가 중복과 빈 구간 없이 이어짐 |
+| 연결 중단 중 문장 작성 | 초안은 유지되고 전송 버튼은 잠김. 연결 복구 뒤 같은 초안이 남음 |
+| 이미지 업로드 실패 후 재시도 | 선택 파일과 작성 중인 문장이 남음. 재시도 뒤 이미지가 한 번 표시됨 |
+| 이전 기록 추가 | 표시된 메시지가 21개에서 40개로 늘어도 기준 메시지의 화면 위치는 167px로 유지됨 |
+| 미확인 전송 후 방 이동과 복귀 | 다른 방에 원문이 나타나지 않음. 원래 방에 돌아오면 대기 항목이 남고 자동 재발행은 없음 |
+| 목록 요청 실패 후 재시도 | 빈 목록 대신 오류를 표시함. 재시도 뒤 두 채팅방이 다시 표시됨 |
+| 너비 390px 화면 | 문서 너비도 390px로 유지됨. 입력창이 하단 탐색 영역 위에 표시됨 |
+
+
+
+
+## 2026년 11월 4일 리뷰 반영
+
+기록 조회가 전송 확인 소켓 응답보다 먼저 도착해도 첫 응답으로 대기 항목을 제거합니다. 이미 처리한 소켓 응답은 다시 확인에 쓰지 않습니다. 전송 전에 이미 있던 메시지도 새 전송을 확인하는 데 쓰지 않습니다. 방을 다시 열어 복원한 미확인 항목은 원래 전송과 응답을 연결할 근거가 없으므로 자동으로 지우지 않습니다. 새로 보낸 동일 내용의 응답은 새 항목만 확인합니다.
+
+다른 화면에서 토큰이 만료된 뒤 채팅에 처음 들어와도 로그인 안내를 띄우기 전에 갱신을 시도합니다. 일시적인 갱신 장애가 생기면 다시 확인 버튼으로 복구할 수 있습니다.
+
+토큰의 만료와 인증 거부를 구분합니다. 상대 정보와 방 목록의 조회도 채팅 화면이 확인한 사용자 ID를 사용하므로 토큰 만료만으로 기존 조회 결과나 입력창이 사라지지 않습니다. 일시적인 네트워크 오류나 서버 장애에서는 로컬 초안과 선택 이미지 및 미확인 전송을 유지합니다. 실제 요청은 갱신이 성공해야 나가며 거절된 토큰을 재사용하지 않습니다. 401과 403으로 갱신이 거부되면 인증과 채팅 캐시를 지웁니다.
+
+로그인과 로그아웃마다 세션 번호를 바꿉니다. 늦게 끝난 이전 세션의 갱신은 새 계정의 토큰이나 오류 상태를 변경할 수 없습니다. 학교 이메일 인증 후 갱신도 같은 처리 경로를 사용합니다.
+
+같은 채팅 세션을 잠깐 중지했다가 재개해도 화면이 관리하는 읽기 위치를 유지합니다. 이 변경을 포함한 단위 테스트 123개와 E2E 44개 및 스토리 28개를 로컬에서 실행했습니다. E2E는 데스크톱과 모바일 Chromium에서 각각 같은 흐름을 검사합니다.
diff --git a/docs/screenshots/chat-system/mobile.png b/docs/screenshots/chat-system/mobile.png
new file mode 100644
index 000000000..5cad709aa
Binary files /dev/null and b/docs/screenshots/chat-system/mobile.png differ
diff --git a/docs/web-browser-testing.md b/docs/web-browser-testing.md
new file mode 100644
index 000000000..6dc3275c1
--- /dev/null
+++ b/docs/web-browser-testing.md
@@ -0,0 +1,105 @@
+# 웹 브라우저 테스트
+
+웹 앱은 세 단계로 검증합니다. Vitest는 상태와 API 처리 로직을 빠르게 확인합니다. Playwright는 실제 Chromium 브라우저에서 화면 이동과 메시지 전송을 확인합니다. Storybook은 컴포넌트를 독립적으로 보여주는 개발 도구입니다. 상태별 화면을 스토리로 남기고 버튼 클릭 같은 상호작용도 브라우저에서 검사합니다.
+
+## 실행
+
+저장소 루트에서 Node.js 22와 pnpm 9를 사용합니다.
+
+```sh
+pnpm install --frozen-lockfile
+pnpm --filter @solid-connect/web exec playwright install chromium
+pnpm --filter @solid-connect/web run ci:check
+pnpm --filter @solid-connect/web run test:e2e
+pnpm --filter @solid-connect/web run test:stories
+pnpm --filter @solid-connect/web run build:storybook
+```
+
+Linux에서 브라우저 실행에 필요한 운영체제 라이브러리까지 설치하려면 `playwright install --with-deps chromium`을 사용합니다.
+
+E2E는 사용자의 화면 진입부터 결과 확인까지 연결해서 검사하는 테스트입니다. `test:e2e`는 테스트 API 서버를 43180 포트에 띄우고 Next.js 개발 서버를 43181 포트에 띄웁니다. 두 포트는 비어 있어야 합니다. 기존 개발 서버를 재사용하지 않으므로 사용자가 열어둔 앱의 데이터나 환경 설정을 바꾸지 않습니다. 테스트가 끝나면 서버도 종료합니다.
+
+```sh
+# 한 화면 크기만 검사
+pnpm --filter @solid-connect/web run test:e2e --project=desktop
+pnpm --filter @solid-connect/web run test:e2e --project=mobile
+
+# 테스트를 선택해서 실행하고 실패 지점을 확인
+pnpm --filter @solid-connect/web run test:e2e:ui
+pnpm --filter @solid-connect/web exec playwright show-report
+
+# 컴포넌트 상태와 문서 보기
+pnpm --filter @solid-connect/web run storybook
+```
+
+Storybook 주소는 `http://localhost:6006`입니다. 빌드 결과는 `apps/web/storybook-static`에 생성됩니다.
+
+## E2E 범위
+
+동일한 시나리오를 데스크톱 Chromium과 390 × 844 모바일 Chromium에서 실행합니다. 모바일은 화면 크기와 터치 환경을 재현하며 실제 휴대전화나 Safari 검증을 대신하지 않습니다.
+
+| 화면 | 확인하는 흐름 |
+| --- | --- |
+| 홈 | 파견 학교 검색 링크와 준비 중인 기능 안내 |
+| 로그인 | 잘못된 비밀번호 안내 후 입력 수정과 로그인 성공 |
+| 마이페이지 | 로그인한 사용자의 프로필 확인과 로그아웃 후 채팅 접근 제한 |
+| 멘토 | 비회원 진입 시 로그인 화면 안내 |
+| 커뮤니티 | 게시글 목록 표시와 글쓰기 진입 및 로그인 후 작성 화면 복귀 |
+| 성적 | 등록 내역이 없는 상태와 공인어학 및 학점 탭 전환과 입력 화면 진입 |
+| 지원자 현황 | 비회원 제한 화면과 로그인 이동 |
+| 약관 및 잘못된 경로 | 약관 표시와 유효하지 않은 채팅방의 오류 화면 |
+| 채팅 전송 | REST 기록과 실시간 메시지 통합 및 한 번 전송과 새로고침 후 유지 |
+| 채팅 기록 | 이전 기록 추가 시 읽던 위치 유지와 조회 실패 후 재시도 |
+| 채팅 연결 | 연결 중단 중 초안 유지와 재연결 후 45개 메시지의 누락 없는 복구 |
+| 채팅 이미지 | 업로드 실패 후 선택 이미지와 텍스트 유지 및 재시도 |
+| 채팅 접근 | 목록 조회 실패 복구와 방 이동 시 기록 분리 및 권한 상실 시 기록 숨김과 전송 차단 |
+| 채팅 인증 | 토큰 갱신의 일시 장애에서 초안과 선택 이미지 보존 및 재연결과 만료 후 첫 진입의 갱신 재시도 |
+| 채팅 확인 | 기록 조회보다 늦은 소켓 응답과 중복 응답을 한 번만 처리 |
+| 채팅 모바일 | 작성 영역이 화면과 하단 탐색 메뉴 안쪽에 배치되는지 확인 |
+
+대학 카탈로그는 `apps/university-web`이 별도로 배포하는 화면입니다. 이번 구성은 웹 앱에서 제공하는 진입 링크를 확인합니다. 카탈로그 내부 검색과 관리 앱은 이 E2E 범위에 포함하지 않습니다. 성적 제출과 지원 완료 및 커뮤니티 작성 저장도 별도 시나리오가 필요합니다.
+
+## 테스트 서버
+
+`apps/web/e2e/fixtures/server.mjs`는 테스트가 사용할 응답과 상태를 가진 로컬 서버입니다. REST는 기록 조회와 업로드를 처리하는 HTTP API입니다. SockJS와 STOMP는 실제 채팅 연결에서 사용하는 통신 방식입니다. 테스트 서버가 이 방식을 받아주므로 앱의 실제 연결 코드를 그대로 거칩니다.
+
+매 테스트가 새 브라우저 환경과 초기화한 서버 상태로 시작합니다. 하나의 서버를 공유하므로 `workers: 1`로 순차 실행합니다. 병렬 실행하려면 먼저 작업자별 서버나 데이터 공간을 분리해야 합니다. 브라우저의 외부 요청을 차단하며 API 주소는 Next.js 기동 전에 로컬 주소로 고정합니다. 구현하지 않은 API가 호출되면 테스트 종료 시 실패합니다.
+
+`/__control`은 연결 중단과 업로드 실패 및 기록 조회 실패 같은 상황을 만드는 테스트 전용 경로입니다. 운영 앱에는 등록하지 않습니다. 테스트 로그인과 토큰도 이 서버에서만 사용합니다.
+
+이 검증은 프론트엔드가 약속된 API 응답에 맞게 동작한다는 의미입니다. 운영 서버에서 두 사용자가 주고받는 메시지의 저장과 실제 전달 및 읽음 반영은 별도의 통합 검증이 필요합니다.
+
+## Storybook 범위
+
+| 컴포넌트 | 스토리 |
+| --- | --- |
+| 공통 버튼 | 기본 버튼과 보조 버튼 및 비활성 상태 |
+| 공통 입력 | 기본 입력과 회색 입력 및 비활성 상태 |
+| 빈 카드 | 기본 안내와 검색 결과 없음 |
+| 프로필 | 멘티와 멘토 및 채팅용 작은 크기 |
+| 탭 | 상태를 바꾸는 탭 선택 |
+| 성적 카드 | 승인 완료와 심사 중 및 승인 거절과 학점 |
+| 채팅 입력 | 전송 성공과 연결 끊김 및 접근 제한과 전송 실패 및 이미지 선택과 업로드 실패 및 전송 중 |
+| 채팅 메시지 | 수신과 발신 및 긴 텍스트와 이미지 및 파일 |
+
+`test:stories`는 28개 스토리를 Chromium에서 렌더링합니다. 버튼 클릭과 입력 및 탭 전환과 메시지 전송과 실패 후 초안 유지도 검사합니다. `fn()`으로 만든 함수가 실제 호출됐는지 확인하므로 렌더링만 성공하는 테스트와 구분합니다. 이미지나 파일 스토리는 상태를 보여주는 예시이며 실제 파일 다운로드 성공을 보장하지 않습니다.
+
+새 스토리는 컴포넌트 옆의 `*.stories.tsx`에 작성합니다. Next.js 경로 기능과 Tailwind 스타일을 공통 설정으로 사용합니다. Storybook 전용 Next.js 설정을 사용하므로 배포용 도메인이나 Sentry 인증 정보가 없어도 실행할 수 있습니다. 앱에서 SVG를 React 컴포넌트로 불러오므로 Storybook도 같은 방식으로 처리합니다.
+
+## CI
+
+GitHub Actions는 웹 앱이나 공유 패키지 또는 루트 설정이 바뀌면 다음 작업을 실행합니다.
+
+| 작업 | 실행 내용 |
+| --- | --- |
+| Web - Quality Check | 기존 `ci:check`로 정적 검사와 타입 검사 및 채팅 회귀 테스트 |
+| Web - Build | 기존 Next.js 프로덕션 빌드 |
+| Web - E2E (desktop) | 로컬 테스트 서버와 개발 서버를 통한 데스크톱 흐름 |
+| Web - E2E (mobile) | 별도의 실행 환경에서 모바일 흐름 |
+| Web - Storybook | 스토리 정적 빌드와 Chromium 렌더링 및 상호작용 검사 |
+
+E2E와 Storybook은 빠른 품질 검사 뒤에 별도 작업으로 실행합니다. E2E 개발 서버 검증과 Next.js 프로덕션 빌드 검증은 서로 다른 단계입니다. 브라우저 테스트는 Husky 커밋 훅에 추가하지 않습니다.
+
+E2E 보고서는 성공 시에도 7일 동안 보관합니다. 실패하면 스크린샷과 비디오 및 trace도 포함합니다. trace는 요청과 화면 변화를 시간 순서로 살펴볼 수 있는 기록입니다. Storybook 빌드와 실패 화면도 7일 동안 보관합니다. 생성된 결과물은 Git에 포함하지 않습니다.
+
+설정 방식은 [Playwright 서버 실행 문서](https://playwright.dev/docs/test-webserver)와 [Storybook Next.js 안내](https://storybook.js.org/docs/get-started/frameworks/nextjs-vite) 및 [Vitest 연동 안내](https://storybook.js.org/docs/writing-tests/integrations/vitest-addon)를 따릅니다.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23457f113..186696fb6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -127,13 +127,13 @@ importers:
version: 5.9.3
vinext:
specifier: ^0.0.51
- version: 0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(next@16.2.9(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(typescript@5.9.3)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ version: 0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(typescript@5.9.3)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
vite:
specifier: ^8.1.0
version: 8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.1.9
- version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
web-vitals:
specifier: ^5.1.0
version: 5.1.0
@@ -145,7 +145,7 @@ importers:
version: 5.2.2(react-hook-form@7.71.1(react@19.2.6))
'@next/third-parties':
specifier: ^16.2.9
- version: 16.2.9(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 16.2.9(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox':
specifier: ^1.1.4
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -163,7 +163,7 @@ importers:
version: 2.20.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@sentry/nextjs':
specifier: ^10.61.0
- version: 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))
+ version: 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))
'@stomp/stompjs':
specifier: ^7.1.1
version: 7.2.1
@@ -178,7 +178,7 @@ importers:
version: 3.13.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/speed-insights':
specifier: ^1.3.1
- version: 1.3.1(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 1.3.1(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
axios:
specifier: ^1.18.1
version: 1.18.1
@@ -199,7 +199,7 @@ importers:
version: 0.479.0(react@19.2.6)
next:
specifier: ^16.2.9
- version: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ version: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-render-analyzer:
specifier: ^0.1.2
version: 0.1.2
@@ -272,7 +272,7 @@ importers:
version: 5.2.2(react-hook-form@7.71.1(react@19.2.6))
'@next/third-parties':
specifier: ^16.2.9
- version: 16.2.9(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 16.2.9(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox':
specifier: ^1.1.4
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -290,7 +290,7 @@ importers:
version: 2.20.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@sentry/nextjs':
specifier: ^10.61.0
- version: 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))
+ version: 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15))
'@solid-connect/ui':
specifier: workspace:^
version: link:../../packages/ui
@@ -308,7 +308,7 @@ importers:
version: 3.13.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/speed-insights':
specifier: ^1.3.1
- version: 1.3.1(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ version: 1.3.1(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
axios:
specifier: ^1.18.1
version: 1.18.1
@@ -329,7 +329,7 @@ importers:
version: 0.479.0(react@19.2.6)
next:
specifier: ^16.2.9
- version: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ version: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-render-analyzer:
specifier: ^0.1.2
version: 0.1.2
@@ -364,9 +364,27 @@ importers:
'@next/bundle-analyzer':
specifier: ^16.2.9
version: 16.2.9
+ '@playwright/test':
+ specifier: 1.64.1
+ version: 1.64.1
+ '@storybook/addon-docs':
+ specifier: 10.6.5
+ version: 10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))
+ '@storybook/addon-vitest':
+ specifier: 10.6.5
+ version: 10.6.5(@vitest/browser-playwright@4.1.9(playwright@1.64.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9))(@vitest/browser@4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))
+ '@storybook/nextjs-vite':
+ specifier: 10.6.5
+ version: 10.6.5(@babel/core@7.29.6)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.62.2)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
'@svgr/webpack':
specifier: ^8.1.0
version: 8.1.0(typescript@5.9.3)
+ '@testing-library/dom':
+ specifier: ^10.4.0
+ version: 10.4.1
+ '@testing-library/react':
+ specifier: ^16.2.0
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@types/node':
specifier: ^20.11.19
version: 20.19.30
@@ -376,6 +394,12 @@ importers:
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.15)
+ '@types/ws':
+ specifier: '8'
+ version: 8.18.2
+ '@vitest/browser-playwright':
+ specifier: 4.1.9
+ version: 4.1.9(playwright@1.64.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9)
autoprefixer:
specifier: ^10.4.20
version: 10.4.23(postcss@8.5.15)
@@ -385,15 +409,33 @@ importers:
critters:
specifier: ^0.0.23
version: 0.0.23
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
postcss:
specifier: 8.5.15
version: 8.5.15
+ storybook:
+ specifier: 10.6.5
+ version: 10.6.5(@types/react@19.2.15)(react@19.2.6)
tailwindcss:
specifier: ^3.4.10
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
typescript:
specifier: ^5.3.3
version: 5.9.3
+ vite:
+ specifier: 8.0.3
+ version: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ vite-plugin-svgr:
+ specifier: 4.5.0
+ version: 4.5.0(rollup@4.62.2)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ ws:
+ specifier: 8.21.0
+ version: 8.21.0
packages/api-schema:
dependencies:
@@ -461,6 +503,9 @@ importers:
packages:
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -1189,6 +1234,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@blazediff/core@1.9.1':
+ resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
+
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -1317,15 +1365,27 @@ packages:
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+ '@emnapi/core@1.11.0':
+ resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
+
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
+ '@emnapi/core@1.9.2':
+ resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
+
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.0':
+ resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
+
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
+ '@emnapi/runtime@1.9.2':
+ resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -1664,6 +1724,15 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
+ resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
+ peerDependencies:
+ typescript: '>= 4.3.x'
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1680,9 +1749,18 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mdx-js/react@3.1.1':
+ resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
+ peerDependencies:
+ '@types/react': '>=16'
+ react: '>=16'
+
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -1803,16 +1881,241 @@ packages:
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
+ '@oxc-parser/binding-android-arm-eabi@0.127.0':
+ resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
+ '@oxc-parser/binding-android-arm64@0.127.0':
+ resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxc-parser/binding-darwin-arm64@0.127.0':
+ resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxc-parser/binding-darwin-x64@0.127.0':
+ resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxc-parser/binding-freebsd-x64@0.127.0':
+ resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0':
+ resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-arm-musleabihf@0.127.0':
+ resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-arm64-gnu@0.127.0':
+ resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-arm64-musl@0.127.0':
+ resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-ppc64-gnu@0.127.0':
+ resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-riscv64-gnu@0.127.0':
+ resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-riscv64-musl@0.127.0':
+ resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-s390x-gnu@0.127.0':
+ resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-x64-gnu@0.127.0':
+ resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-x64-musl@0.127.0':
+ resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxc-parser/binding-openharmony-arm64@0.127.0':
+ resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxc-parser/binding-wasm32-wasi@0.127.0':
+ resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@oxc-parser/binding-win32-arm64-msvc@0.127.0':
+ resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxc-parser/binding-win32-ia32-msvc@0.127.0':
+ resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@oxc-parser/binding-win32-x64-msvc@0.127.0':
+ resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@oxc-project/types@0.122.0':
+ resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
+
+ '@oxc-project/types@0.127.0':
+ resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
+
'@oxc-project/types@0.132.0':
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
'@oxc-project/types@0.137.0':
resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==}
+ '@oxc-resolver/binding-android-arm-eabi@11.21.2':
+ resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@oxc-resolver/binding-android-arm64@11.21.2':
+ resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxc-resolver/binding-darwin-arm64@11.21.2':
+ resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxc-resolver/binding-darwin-x64@11.21.2':
+ resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxc-resolver/binding-freebsd-x64@11.21.2':
+ resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
+ resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
+ resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
+ resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-arm64-musl@11.21.2':
+ resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
+ resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
+ resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
+ resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
+ resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-x64-gnu@11.21.2':
+ resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-x64-musl@11.21.2':
+ resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxc-resolver/binding-openharmony-arm64@11.21.2':
+ resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxc-resolver/binding-wasm32-wasi@11.21.2':
+ resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
+ resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxc-resolver/binding-win32-x64-msvc@11.21.2':
+ resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==}
+ cpu: [x64]
+ os: [win32]
+
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.64.1':
+ resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2193,6 +2496,12 @@ packages:
resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==}
engines: {node: '>= 10'}
+ '@rolldown/binding-android-arm64@1.0.0-rc.12':
+ resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2205,6 +2514,12 @@ packages:
cpu: [arm64]
os: [android]
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.12':
+ resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
'@rolldown/binding-darwin-arm64@1.0.2':
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2217,6 +2532,12 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@rolldown/binding-darwin-x64@1.0.0-rc.12':
+ resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
'@rolldown/binding-darwin-x64@1.0.2':
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2229,6 +2550,12 @@ packages:
cpu: [x64]
os: [darwin]
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.12':
+ resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
'@rolldown/binding-freebsd-x64@1.0.2':
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2241,6 +2568,12 @@ packages:
cpu: [x64]
os: [freebsd]
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
+ resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2253,6 +2586,12 @@ packages:
cpu: [arm]
os: [linux]
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
'@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2265,6 +2604,12 @@ packages:
cpu: [arm64]
os: [linux]
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
+ resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2277,6 +2622,12 @@ packages:
cpu: [arm64]
os: [linux]
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2289,6 +2640,12 @@ packages:
cpu: [ppc64]
os: [linux]
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
'@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2301,6 +2658,12 @@ packages:
cpu: [s390x]
os: [linux]
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
+ resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
'@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2313,6 +2676,12 @@ packages:
cpu: [x64]
os: [linux]
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
+ resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
'@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2325,6 +2694,12 @@ packages:
cpu: [x64]
os: [linux]
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
+ resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
'@rolldown/binding-openharmony-arm64@1.0.2':
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2337,6 +2712,11 @@ packages:
cpu: [arm64]
os: [openharmony]
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
+ resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
'@rolldown/binding-wasm32-wasi@1.0.2':
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2347,6 +2727,12 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
+ resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
'@rolldown/binding-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2359,6 +2745,12 @@ packages:
cpu: [arm64]
os: [win32]
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
+ resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
'@rolldown/binding-win32-x64-msvc@1.0.2':
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2371,6 +2763,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@rolldown/pluginutils@1.0.0-rc.12':
+ resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
+
'@rolldown/pluginutils@1.0.0-rc.18':
resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==}
@@ -2689,29 +3084,129 @@ packages:
'@stomp/stompjs@7.2.1':
resolution: {integrity: sha512-DLd/WeicnHS5SsWWSk3x6/pcivqchNaEvg9UEGVqAcfYEBVmS9D6980ckXjTtfpXLjdLDsd96M7IuX4w7nzq5g==}
- '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
- resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
- engines: {node: '>=14'}
+ '@storybook/addon-docs@10.6.5':
+ resolution: {integrity: sha512-Kt0pGLA+nwdu2g80p8jbdoiiKXxwQbhrY6uzMzq/0G1lytxpJUzrOQAM+GDcEbqyvpKr0u5d1q05/LPem/uooQ==}
peerDependencies:
- '@babel/core': 7.29.6
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.6.5
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
- '@svgr/babel-plugin-remove-jsx-attribute@8.0.0':
- resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==}
- engines: {node: '>=14'}
+ '@storybook/addon-vitest@10.6.5':
+ resolution: {integrity: sha512-0N0E7TQgmdrPNCPqNiVdTWOEQ1Nes+YsUphYX/abzlp6ReOyYBoQJw6mZ0b7BN5ACgdXQsr9Tth161t72k+51Q==}
peerDependencies:
- '@babel/core': 7.29.6
+ '@vitest/browser': ^4.0.0 || ^5.0.0
+ '@vitest/browser-playwright': ^4.0.0 || ^5.0.0
+ storybook: ^10.6.5
+ vitest: ^4.0.0 || ^5.0.0
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ vitest:
+ optional: true
- '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0':
- resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==}
- engines: {node: '>=14'}
+ '@storybook/builder-vite@10.6.5':
+ resolution: {integrity: sha512-udrGjg3yt38pz4FxplypXxw1creIGS7N6QS8xfvTFM8MQNtPyojT/gZdd4fhX0dFrQx5MMuJEYJA5gUBiPsLtg==}
peerDependencies:
- '@babel/core': 7.29.6
+ storybook: ^10.6.5
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
- '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0':
- resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==}
- engines: {node: '>=14'}
- peerDependencies:
- '@babel/core': 7.29.6
+ '@storybook/global@5.0.0':
+ resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
+
+ '@storybook/icons@2.2.0':
+ resolution: {integrity: sha512-FpUB/xizIrV1ILhCIYFo+TtQNN9YEoLjfcnAW0JtOXL+BcM2aisSysI0B54JMWDnljriXySBHgN6ps4s9lb75A==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@storybook/nextjs-vite@10.6.5':
+ resolution: {integrity: sha512-33xNYeCMU/i2ZM0XmOkjw3bldbMyUn2uhKjXq/qwXTYIk7L7o72a3MH+16jMTN1jjlATYEeOqgwmldV/6If9eg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ next: ^14.1.0 || ^15.0.0 || ^16.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.6.5
+ typescript: '*'
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ typescript:
+ optional: true
+
+ '@storybook/react-dom-shim@10.6.5':
+ resolution: {integrity: sha512-Qbbkp+5Otg32w8Ddav6rH6MGPiYVfEzrgn0HqbqlqS8kY+DRXo+NYXJwZTVSDSwrYPrfH+Ok2tV4uE8Zo0c3cg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.6.5
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@storybook/react-vite@10.6.5':
+ resolution: {integrity: sha512-RsRF1drYtCku7+PMdRnuFt3EdG8Rn8I5G8ne0cBjn24xSn5Pbo9W+y9IoyD2llAeaeCpzEEdASY12wpKd3aCQA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.6.5
+ typescript: '>= 4.9.x'
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@storybook/react@10.6.5':
+ resolution: {integrity: sha512-vvRjRe/O8NRiQbGyA7Ne6h2jbD6GjwNZFkMdHl+0kZH8Z/S9ZWWmKmjCySvH/OmgighdCOQ98tcfND7m65WQBg==}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.6.5
+ typescript: '>= 4.9.x'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ typescript:
+ optional: true
+
+ '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
+ resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ '@babel/core': 7.29.6
+
+ '@svgr/babel-plugin-remove-jsx-attribute@8.0.0':
+ resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ '@babel/core': 7.29.6
+
+ '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0':
+ resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ '@babel/core': 7.29.6
+
+ '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0':
+ resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ '@babel/core': 7.29.6
'@svgr/babel-plugin-svg-dynamic-title@8.0.0':
resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==}
@@ -2890,6 +3385,10 @@ packages:
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
@@ -2905,6 +3404,12 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.6.8':
+ resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@turbo/darwin-64@2.10.0':
resolution: {integrity: sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg==}
cpu: [x64]
@@ -2944,12 +3449,27 @@ packages:
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/doctrine@0.0.9':
+ resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
+
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -2968,6 +3488,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/mdx@2.0.14':
+ resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==}
+
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
@@ -2988,9 +3511,15 @@ packages:
'@types/react@19.2.15':
resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==}
+ '@types/resolve@1.20.6':
+ resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
+
'@types/sockjs-client@1.5.4':
resolution: {integrity: sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==}
+ '@types/ws@8.18.2':
+ resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
+
'@unpic/core@1.0.3':
resolution: {integrity: sha512-aum9YNVUGso7MjGLD0Rp/08kywCGLqZ03/q6VQBFFakDBOXWEc8D4kPGcZ8v5wEnGRex3lE+++bOuucBp3KJ/w==}
@@ -3055,6 +3584,20 @@ packages:
react-server-dom-webpack:
optional: true
+ '@vitest/browser-playwright@4.1.9':
+ resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==}
+ peerDependencies:
+ playwright: '*'
+ vitest: 4.1.9
+
+ '@vitest/browser@4.1.9':
+ resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==}
+ peerDependencies:
+ vitest: 4.1.9
+
+ '@vitest/expect@3.2.4':
+ resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
+
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
@@ -3069,6 +3612,9 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@3.2.4':
+ resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
+
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
@@ -3078,9 +3624,15 @@ packages:
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
+ '@vitest/spy@3.2.4':
+ resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
+ '@vitest/utils@3.2.4':
+ resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
+
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
@@ -3129,6 +3681,9 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
+ '@webcontainer/env@1.1.1':
+ resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==}
+
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -3231,6 +3786,10 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ ast-types@0.16.3:
+ resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==}
+ engines: {node: '>=4'}
+
astring@1.9.0:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
@@ -3305,6 +3864,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ bundle-name@4.1.1:
+ resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==}
+ engines: {node: '>=18'}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -3327,6 +3890,10 @@ packages:
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -3335,6 +3902,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -3492,6 +4063,9 @@ packages:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -3557,10 +4131,26 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ default-browser-id@5.0.1:
+ resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
+ engines: {node: '>=18'}
+
+ default-browser@5.5.1:
+ resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==}
+ engines: {node: '>=18'}
+
+ define-lazy-prop@3.0.0:
+ resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+ engines: {node: '>=12'}
+
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -3582,9 +4172,16 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+ doctrine@3.0.0:
+ resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
+ engines: {node: '>=6.0.0'}
+
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -3635,6 +4232,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ empathic@2.1.0:
+ resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==}
+ engines: {node: '>=14'}
+
enhanced-resolve@5.18.4:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
@@ -3718,6 +4319,11 @@ packages:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
+
esquery@1.7.0:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
@@ -3962,6 +4568,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ iconv-lite@0.4.24:
+ resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+ engines: {node: '>=0.10.0'}
+
image-size@2.0.2:
resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==}
engines: {node: '>=16.x'}
@@ -3975,6 +4585,10 @@ packages:
resolution: {integrity: sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==}
engines: {node: '>=18'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -4000,6 +4614,11 @@ packages:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
+ is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -4012,6 +4631,11 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
@@ -4034,6 +4658,10 @@ packages:
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
+ is-wsl@3.1.1:
+ resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
+ engines: {node: '>=16'}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -4087,6 +4715,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
kdbush@4.0.2:
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
@@ -4260,10 +4891,16 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
@@ -4294,6 +4931,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magic-string@1.4.5:
+ resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -4334,6 +4974,10 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -4342,10 +4986,16 @@ packages:
resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==}
engines: {node: '>=16 || 14 >=14.17'}
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
+ module-alias@2.3.5:
+ resolution: {integrity: sha512-hbei6S1tXkg/P+ZJTyUqqL66LOeg/40WGcZYPFzIeBltTCRm+O4IZ/KRtR9hGanXaJxuvPj9q9ldl/cmbV6Osw==}
+
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
@@ -4364,6 +5014,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ needle@2.9.1:
+ resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
+ engines: {node: '>= 4.4.x'}
+ hasBin: true
+
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -4471,10 +5126,21 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+ open@10.2.0:
+ resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
+ engines: {node: '>=18'}
+
opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
+ oxc-parser@0.127.0:
+ resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+
+ oxc-resolver@11.21.2:
+ resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==}
+
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -4529,6 +5195,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -4548,6 +5218,20 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
+ playwright-core@1.64.1:
+ resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.64.1:
+ resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ pngjs@7.0.0:
+ resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+ engines: {node: '>=14.19.0'}
+
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -4602,6 +5286,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ probe-image-size@7.5.0:
+ resolution: {integrity: sha512-ietdx4XPgmnzHRZOq1/KtcnsBkL4bRsBe+Szj5Tuq1/KPqDG42OspG1IhJb7vz6Z9pSzhp0J6UPnw6hujJTWoQ==}
+
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -4623,6 +5310,15 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ react-docgen-typescript@2.4.0:
+ resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
+ peerDependencies:
+ typescript: '>= 4.3.x'
+
+ react-docgen@8.0.4:
+ resolution: {integrity: sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ==}
+ engines: {node: ^20.9.0 || >=22}
+
react-dom@19.2.6:
resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
@@ -4693,6 +5389,14 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ recast@0.23.21:
+ resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
+ engines: {node: '>= 4'}
+
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
regenerate-unicode-properties@10.2.2:
resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==}
engines: {node: '>=4'}
@@ -4742,6 +5446,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rolldown@1.0.0-rc.12:
+ resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4760,12 +5469,19 @@ packages:
rou3@0.8.1:
resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==}
+ run-applescript@7.1.0:
+ resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
+ engines: {node: '>=18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
satori@0.16.0:
resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==}
engines: {node: '>=16'}
@@ -4820,6 +5536,10 @@ packages:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -4859,6 +5579,21 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+ storybook@10.6.5:
+ resolution: {integrity: sha512-qurC38Nx4PDF9YIq6GO95dpUx8a3B9AZwlcHBo4cOI9At09ifjQRsqcwcdOf2YX0cpVhFKmHkI20SWCrxqYQrg==}
+ hasBin: true
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ prettier: ^2 || ^3
+ vite-plus: ^0.1.15 || ^0.2.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ prettier:
+ optional: true
+ vite-plus:
+ optional: true
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -4882,6 +5617,18 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
+ strip-bom@3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+ engines: {node: '>=4'}
+
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
+ strip-indent@4.1.1:
+ resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
+ engines: {node: '>=12'}
+
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
@@ -5014,6 +5761,9 @@ packages:
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -5029,10 +5779,18 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+ engines: {node: '>=14.0.0'}
+
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
+ tinyspy@4.0.6:
+ resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
+ engines: {node: '>=14.0.0'}
+
tldts-core@7.0.21:
resolution: {integrity: sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==}
@@ -5059,6 +5817,10 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ ts-dedent@2.3.0:
+ resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
+ engines: {node: '>=6.10'}
+
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -5072,6 +5834,10 @@ packages:
typescript:
optional: true
+ tsconfig-paths@4.2.0:
+ resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
+ engines: {node: '>=6'}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -5128,6 +5894,10 @@ packages:
unpic@4.2.2:
resolution: {integrity: sha512-z6T2ScMgRV2y2H8MwwhY5xHZWXhUx/YxtOCGJwfURSl7ypVy4HpLIMWoIZKnnxQa/RKzM0kg8hUh0paIrpLfvw==}
+ unplugin@2.3.11:
+ resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
+ engines: {node: '>=18.12.0'}
+
unstorage@2.0.0-alpha.7:
resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==}
peerDependencies:
@@ -5265,11 +6035,74 @@ packages:
vite-plugin-dynamic-import@1.6.0:
resolution: {integrity: sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg==}
+ vite-plugin-storybook-nextjs@10.6.5:
+ resolution: {integrity: sha512-XFHY6nscLgxtLvmuc68gaN3/6chIPGVY85CwXJnPCVfV64JgWTQhfu0NS+/EhlxpVW/ghCiVG3BEtB89pBvXtQ==}
+ peerDependencies:
+ next: ^14.1.0 || ^15.0.0 || ^16.0.0
+ storybook: ^10.6.5
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ vite-plugin-svgr@4.5.0:
+ resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==}
+ peerDependencies:
+ vite: '>=2.6.0'
+
+ vite-tsconfig-paths@5.1.4:
+ resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==}
+ peerDependencies:
+ vite: '*'
+ peerDependenciesMeta:
+ vite:
+ optional: true
+
vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies:
vite: '*'
+ vite@8.0.3:
+ resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.0
+ esbuild: 0.28.1
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: 2.8.3
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
vite@8.1.0:
resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5396,6 +6229,9 @@ packages:
resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==}
engines: {node: '>=10.13.0'}
+ webpack-virtual-modules@0.6.2:
+ resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
webpack@5.104.1:
resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==}
engines: {node: '>=10.13.0'}
@@ -5459,6 +6295,34 @@ packages:
utf-8-validate:
optional: true
+ ws@8.21.0:
+ resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ ws@8.22.1:
+ resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ wsl-utils@0.1.0:
+ resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
+ engines: {node: '>=18'}
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -5516,6 +6380,8 @@ packages:
snapshots:
+ '@adobe/css-tools@4.5.0': {}
+
'@alloc/quick-lru@5.2.0': {}
'@apm-js-collab/code-transformer-bundler-plugins@0.5.0':
@@ -5564,7 +6430,7 @@ snapshots:
'@babel/code-frame@7.28.6':
dependencies:
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
@@ -6297,7 +7163,7 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
- '@babel/code-frame': 7.28.6
+ '@babel/code-frame': 7.29.7
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
@@ -6411,6 +7277,8 @@ snapshots:
'@biomejs/cli-win32-x64@2.3.12':
optional: true
+ '@blazediff/core@1.9.1': {}
+
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -6564,22 +7432,44 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/core@1.11.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.2
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/core@1.11.1':
dependencies:
'@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
optional: true
+ '@emnapi/core@1.9.2':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.9.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
@@ -6805,6 +7695,14 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
+ dependencies:
+ glob: 13.0.6
+ react-docgen-typescript: 2.4.0(typescript@5.9.3)
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -6824,11 +7722,19 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mdx-js/react@3.1.1(@types/react@19.2.15)(react@19.2.6)':
+ dependencies:
+ '@types/mdx': 2.0.14
+ '@types/react': 19.2.15
+ react: 19.2.6
+
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -6836,6 +7742,13 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
+ dependencies:
+ '@emnapi/core': 1.11.0
+ '@emnapi/runtime': 1.11.0
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
@@ -6843,6 +7756,13 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
+ dependencies:
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
'@next/bundle-analyzer@16.2.9':
dependencies:
webpack-bundle-analyzer: 4.10.1
@@ -6876,9 +7796,9 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.9':
optional: true
- '@next/third-parties@16.2.9(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ '@next/third-parties@16.2.9(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
dependencies:
- next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
third-party-capital: 1.0.20
@@ -6929,13 +7849,146 @@ snapshots:
'@opentelemetry/semantic-conventions@1.41.1': {}
+ '@oxc-parser/binding-android-arm-eabi@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-android-arm64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-darwin-arm64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-darwin-x64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-freebsd-x64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm-musleabihf@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm64-musl@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-ppc64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-riscv64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-riscv64-musl@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-s390x-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-x64-gnu@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-x64-musl@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-openharmony-arm64@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-wasm32-wasi@0.127.0':
+ dependencies:
+ '@emnapi/core': 1.9.2
+ '@emnapi/runtime': 1.9.2
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
+ optional: true
+
+ '@oxc-parser/binding-win32-arm64-msvc@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-ia32-msvc@0.127.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-x64-msvc@0.127.0':
+ optional: true
+
+ '@oxc-project/types@0.122.0': {}
+
+ '@oxc-project/types@0.127.0': {}
+
'@oxc-project/types@0.132.0': {}
'@oxc-project/types@0.137.0': {}
+ '@oxc-resolver/binding-android-arm-eabi@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-android-arm64@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-darwin-arm64@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-darwin-x64@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-freebsd-x64@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm64-musl@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-x64-gnu@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-x64-musl@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-openharmony-arm64@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-wasm32-wasi@11.21.2':
+ dependencies:
+ '@emnapi/core': 1.11.0
+ '@emnapi/runtime': 1.11.0
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
+ optional: true
+
+ '@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
+ optional: true
+
+ '@oxc-resolver/binding-win32-x64-msvc@11.21.2':
+ optional: true
+
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.64.1':
+ dependencies:
+ playwright: 1.64.1
+
'@polka/url@1.0.0-next.29': {}
'@radix-ui/number@1.1.1': {}
@@ -7273,78 +8326,122 @@ snapshots:
'@resvg/resvg-wasm@2.4.0': {}
+ '@rolldown/binding-android-arm64@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-android-arm64@1.0.2':
optional: true
'@rolldown/binding-android-arm64@1.1.3':
optional: true
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-darwin-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-arm64@1.1.3':
optional: true
+ '@rolldown/binding-darwin-x64@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-darwin-x64@1.0.2':
optional: true
'@rolldown/binding-darwin-x64@1.1.3':
optional: true
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-freebsd-x64@1.0.2':
optional: true
'@rolldown/binding-freebsd-x64@1.1.3':
optional: true
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.1.3':
optional: true
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-arm64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.1.3':
optional: true
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-arm64-musl@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-musl@1.1.3':
optional: true
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
optional: true
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-s390x-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.1.3':
optional: true
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-x64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-gnu@1.1.3':
optional: true
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-linux-x64-musl@1.0.2':
optional: true
'@rolldown/binding-linux-x64-musl@1.1.3':
optional: true
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-openharmony-arm64@1.0.2':
optional: true
'@rolldown/binding-openharmony-arm64@1.1.3':
optional: true
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
+ dependencies:
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ optional: true
+
'@rolldown/binding-wasm32-wasi@1.0.2':
dependencies:
'@emnapi/core': 1.10.0
@@ -7359,18 +8456,26 @@ snapshots:
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-win32-arm64-msvc@1.0.2':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.1.3':
optional: true
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
+ optional: true
+
'@rolldown/binding-win32-x64-msvc@1.0.2':
optional: true
'@rolldown/binding-win32-x64-msvc@1.1.3':
optional: true
+ '@rolldown/pluginutils@1.0.0-rc.12': {}
+
'@rolldown/pluginutils@1.0.0-rc.18': {}
'@rolldown/pluginutils@1.0.1': {}
@@ -7549,7 +8654,32 @@ snapshots:
dependencies:
'@sentry/core': 10.61.0
- '@sentry/nextjs@10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))':
+ '@sentry/nextjs@10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15))':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@rollup/plugin-commonjs': 28.0.1(rollup@4.62.2)
+ '@sentry/browser-utils': 10.61.0
+ '@sentry/bundler-plugin-core': 5.3.0
+ '@sentry/conventions': 0.12.0
+ '@sentry/core': 10.61.0
+ '@sentry/node': 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))
+ '@sentry/opentelemetry': 10.61.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))
+ '@sentry/react': 10.61.0(react@19.2.6)
+ '@sentry/vercel-edge': 10.61.0
+ '@sentry/webpack-plugin': 5.3.0(webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15))
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ rollup: 4.62.2
+ stacktrace-parser: 0.1.11
+ transitivePeerDependencies:
+ - '@opentelemetry/core'
+ - '@opentelemetry/exporter-trace-otlp-http'
+ - '@opentelemetry/sdk-trace-base'
+ - encoding
+ - react
+ - supports-color
+ - webpack
+
+ '@sentry/nextjs@10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(webpack@5.104.1(postcss@8.5.15))':
dependencies:
'@opentelemetry/api': 1.9.1
'@rollup/plugin-commonjs': 28.0.1(rollup@4.62.2)
@@ -7562,7 +8692,7 @@ snapshots:
'@sentry/react': 10.61.0(react@19.2.6)
'@sentry/vercel-edge': 10.61.0
'@sentry/webpack-plugin': 5.3.0(webpack@5.104.1(postcss@8.5.15))
- next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
rollup: 4.62.2
stacktrace-parser: 0.1.11
transitivePeerDependencies:
@@ -7642,6 +8772,14 @@ snapshots:
'@opentelemetry/api': 1.9.1
'@sentry/core': 10.61.0
+ '@sentry/webpack-plugin@5.3.0(webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15))':
+ dependencies:
+ '@sentry/bundler-plugin-core': 5.3.0
+ webpack: 5.104.1(esbuild@0.28.1)(postcss@8.5.15)
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
'@sentry/webpack-plugin@5.3.0(webpack@5.104.1(postcss@8.5.15))':
dependencies:
'@sentry/bundler-plugin-core': 5.3.0
@@ -7667,6 +8805,115 @@ snapshots:
'@stomp/stompjs@7.2.1': {}
+ '@storybook/addon-docs@10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))':
+ dependencies:
+ '@mdx-js/react': 3.1.1(@types/react@19.2.15)(react@19.2.6)
+ '@storybook/icons': 2.2.0(react@19.2.6)
+ '@storybook/react-dom-shim': 10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ ts-dedent: 2.3.0
+ unplugin: 2.3.11
+ optionalDependencies:
+ '@types/react': 19.2.15
+ transitivePeerDependencies:
+ - '@types/react-dom'
+
+ '@storybook/addon-vitest@10.6.5(@vitest/browser-playwright@4.1.9(playwright@1.64.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9))(@vitest/browser@4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))':
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/icons': 2.2.0(react@19.2.6)
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ optionalDependencies:
+ '@vitest/browser': 4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))
+ '@vitest/browser-playwright': 4.1.9(playwright@1.64.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9)
+ vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ transitivePeerDependencies:
+ - react
+
+ '@storybook/builder-vite@10.6.5(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
+ dependencies:
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ ts-dedent: 2.3.0
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+
+ '@storybook/global@5.0.0': {}
+
+ '@storybook/icons@2.2.0(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+
+ '@storybook/nextjs-vite@10.6.5(@babel/core@7.29.6)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.62.2)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
+ dependencies:
+ '@storybook/builder-vite': 10.6.5(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@storybook/react': 10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)
+ '@storybook/react-vite': 10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.62.2)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.6)
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ vite-plugin-storybook-nextjs: 10.6.5(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(rollup@4.62.2)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ optionalDependencies:
+ '@types/react': 19.2.15
+ '@types/react-dom': 19.2.3(@types/react@19.2.15)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+ - rollup
+ - supports-color
+
+ '@storybook/react-dom-shim@10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))':
+ dependencies:
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.2.15
+ '@types/react-dom': 19.2.3(@types/react@19.2.15)
+
+ '@storybook/react-vite@10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.62.2)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
+ dependencies:
+ '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@rollup/pluginutils': 5.3.0(rollup@4.62.2)
+ '@storybook/builder-vite': 10.6.5(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@storybook/react': 10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)
+ empathic: 2.1.0
+ magic-string: 1.4.5
+ react: 19.2.6
+ react-docgen: 8.0.4
+ react-dom: 19.2.6(react@19.2.6)
+ resolve: 1.22.11
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ tsconfig-paths: 4.2.0
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+ - rollup
+ - supports-color
+
+ '@storybook/react@10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)':
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/react-dom-shim': 10.6.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))
+ react: 19.2.6
+ react-docgen: 8.0.4
+ react-docgen-typescript: 2.4.0(typescript@5.9.3)
+ react-dom: 19.2.6(react@19.2.6)
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.2.15
+ '@types/react-dom': 19.2.3(@types/react@19.2.15)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.6)':
dependencies:
'@babel/core': 7.29.6
@@ -7857,7 +9104,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
- '@babel/code-frame': 7.28.6
+ '@babel/code-frame': 7.29.7
'@babel/runtime': 7.28.6
'@types/aria-query': 5.0.4
aria-query: 5.3.0
@@ -7866,6 +9113,15 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@babel/runtime': 7.28.6
@@ -7876,6 +9132,20 @@ snapshots:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@testing-library/dom': 10.4.1
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.2.15
+ '@types/react-dom': 19.2.3(@types/react@19.2.15)
+
+ '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@turbo/darwin-64@2.10.0':
optional: true
@@ -7906,6 +9176,27 @@ snapshots:
'@types/aria-query@5.0.4': {}
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -7913,6 +9204,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/doctrine@0.0.9': {}
+
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -7931,6 +9224,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/mdx@2.0.14': {}
+
'@types/node@20.19.30':
dependencies:
undici-types: 6.21.0
@@ -7959,28 +9254,34 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/resolve@1.20.6': {}
+
'@types/sockjs-client@1.5.4': {}
+ '@types/ws@8.18.2':
+ dependencies:
+ '@types/node': 20.19.43
+
'@unpic/core@1.0.3':
dependencies:
unpic: 4.2.2
- '@unpic/react@1.0.2(next@16.2.9(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@unpic/react@1.0.2(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@unpic/core': 1.0.3
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
- next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/og@0.8.6':
dependencies:
'@resvg/resvg-wasm': 2.4.0
satori: 0.16.0
- '@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ '@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
optionalDependencies:
- next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
'@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
@@ -8006,6 +9307,76 @@ snapshots:
optionalDependencies:
react-server-dom-webpack: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1))
+ '@vitest/browser-playwright@4.1.9(playwright@1.64.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9)':
+ dependencies:
+ '@vitest/browser': 4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))
+ '@vitest/mocker': 4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ playwright: 1.64.1
+ tinyrainbow: 3.1.0
+ vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/browser-playwright@4.1.9(playwright@1.64.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9)':
+ dependencies:
+ '@vitest/browser': 4.1.9(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))
+ '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ playwright: 1.64.1
+ tinyrainbow: 3.1.0
+ vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+ optional: true
+
+ '@vitest/browser@4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))':
+ dependencies:
+ '@blazediff/core': 1.9.1
+ '@vitest/mocker': 4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/utils': 4.1.9
+ magic-string: 0.30.21
+ pngjs: 7.0.0
+ sirv: 3.0.2
+ tinyrainbow: 3.1.0
+ vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/browser@4.1.9(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))':
+ dependencies:
+ '@blazediff/core': 1.9.1
+ '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/utils': 4.1.9
+ magic-string: 0.30.21
+ pngjs: 7.0.0
+ sirv: 3.0.2
+ tinyrainbow: 3.1.0
+ vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+ optional: true
+
+ '@vitest/expect@3.2.4':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.4
+ '@vitest/utils': 3.2.4
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
+
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -8015,6 +9386,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
+ '@vitest/mocker@4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
+ dependencies:
+ '@vitest/spy': 4.1.9
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.9
@@ -8023,6 +9402,10 @@ snapshots:
optionalDependencies:
vite: 8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ '@vitest/pretty-format@3.2.4':
+ dependencies:
+ tinyrainbow: 2.0.0
+
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
@@ -8039,8 +9422,18 @@ snapshots:
magic-string: 0.30.21
pathe: 2.0.3
+ '@vitest/spy@3.2.4':
+ dependencies:
+ tinyspy: 4.0.6
+
'@vitest/spy@4.1.9': {}
+ '@vitest/utils@3.2.4':
+ dependencies:
+ '@vitest/pretty-format': 3.2.4
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
@@ -8123,6 +9516,8 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
+ '@webcontainer/env@1.1.1': {}
+
'@xtuc/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {}
@@ -8204,6 +9599,10 @@ snapshots:
assertion-error@2.0.1: {}
+ ast-types@0.16.3:
+ dependencies:
+ tslib: 2.8.1
+
astring@1.9.0: {}
asynckit@0.4.0: {}
@@ -8287,6 +9686,10 @@ snapshots:
buffer-from@1.1.2: {}
+ bundle-name@4.1.1:
+ dependencies:
+ run-applescript: 7.1.0
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -8302,6 +9705,14 @@ snapshots:
caniuse-lite@1.0.30001799: {}
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
chai@6.2.2: {}
chalk@4.1.2:
@@ -8309,6 +9720,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ check-error@2.1.3: {}
+
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -8469,6 +9882,8 @@ snapshots:
css-what@6.2.2: {}
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
csso@5.0.5:
@@ -8500,8 +9915,19 @@ snapshots:
decimal.js@10.6.0: {}
+ deep-eql@5.0.2: {}
+
deepmerge@4.3.1: {}
+ default-browser-id@5.0.1: {}
+
+ default-browser@5.5.1:
+ dependencies:
+ bundle-name: 4.1.1
+ default-browser-id: 5.0.1
+
+ define-lazy-prop@3.0.0: {}
+
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
@@ -8514,8 +9940,14 @@ snapshots:
dlv@1.1.3: {}
+ doctrine@3.0.0:
+ dependencies:
+ esutils: 2.0.3
+
dom-accessibility-api@0.5.16: {}
+ dom-accessibility-api@0.6.3: {}
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -8565,6 +9997,8 @@ snapshots:
emoji-regex@9.2.2: {}
+ empathic@2.1.0: {}
+
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
@@ -8641,7 +10075,6 @@ snapshots:
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
- optional: true
escalade@3.2.0: {}
@@ -8654,6 +10087,8 @@ snapshots:
esrecurse: 4.3.0
estraverse: 4.3.0
+ esprima@4.0.1: {}
+
esquery@1.7.0:
dependencies:
estraverse: 5.3.0
@@ -8876,6 +10311,10 @@ snapshots:
husky@9.1.7: {}
+ iconv-lite@0.4.24:
+ dependencies:
+ safer-buffer: 2.1.2
+
image-size@2.0.2: {}
import-fresh@3.3.1:
@@ -8890,6 +10329,8 @@ snapshots:
cjs-module-lexer: 2.2.0
module-details-from-path: 1.0.4
+ indent-string@4.0.0: {}
+
inherits@2.0.4: {}
ini@6.0.0: {}
@@ -8910,6 +10351,8 @@ snapshots:
dependencies:
hasown: 2.0.2
+ is-docker@3.0.0: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -8918,6 +10361,10 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-inside-container@1.0.0:
+ dependencies:
+ is-docker: 3.0.0
+
is-number@7.0.0: {}
is-obj@2.0.0: {}
@@ -8932,6 +10379,10 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
+ is-wsl@3.1.1:
+ dependencies:
+ is-inside-container: 1.0.0
+
isexe@2.0.0: {}
jackspeak@3.4.3:
@@ -8992,6 +10443,8 @@ snapshots:
json5@2.2.3: {}
+ jsonc-parser@3.3.1: {}
+
kdbush@4.0.2: {}
lightningcss-android-arm64@1.30.2:
@@ -9116,10 +10569,14 @@ snapshots:
lodash.debounce@4.0.8: {}
+ lodash.merge@4.6.2: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
+ loupe@3.2.1: {}
+
lower-case@2.0.2:
dependencies:
tslib: 2.8.1
@@ -9146,6 +10603,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magic-string@1.4.5:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
math-intrinsics@1.1.0: {}
mdn-data@2.0.28: {}
@@ -9173,6 +10634,8 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ min-indent@1.0.1: {}
+
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -9181,8 +10644,12 @@ snapshots:
dependencies:
brace-expansion: 5.0.6
+ minimist@1.2.8: {}
+
minipass@7.1.3: {}
+ module-alias@2.3.5: {}
+
module-details-from-path@1.0.4: {}
mrmime@2.0.1: {}
@@ -9197,6 +10664,14 @@ snapshots:
nanoid@3.3.12: {}
+ needle@2.9.1:
+ dependencies:
+ debug: 3.2.7
+ iconv-lite: 0.4.24
+ sax: 1.6.0
+ transitivePeerDependencies:
+ - supports-color
+
neo-async@2.6.2: {}
next-render-analyzer@0.1.2:
@@ -9207,7 +10682,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@next/env': 16.2.9
'@swc/helpers': 0.5.15
@@ -9227,6 +10702,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.2.9
'@next/swc-win32-x64-msvc': 16.2.9
'@opentelemetry/api': 1.9.1
+ '@playwright/test': 1.64.1
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5
transitivePeerDependencies:
@@ -9318,8 +10794,62 @@ snapshots:
ohash@2.0.11: {}
+ open@10.2.0:
+ dependencies:
+ default-browser: 5.5.1
+ define-lazy-prop: 3.0.0
+ is-inside-container: 1.0.0
+ wsl-utils: 0.1.0
+
opener@1.5.2: {}
+ oxc-parser@0.127.0:
+ dependencies:
+ '@oxc-project/types': 0.127.0
+ optionalDependencies:
+ '@oxc-parser/binding-android-arm-eabi': 0.127.0
+ '@oxc-parser/binding-android-arm64': 0.127.0
+ '@oxc-parser/binding-darwin-arm64': 0.127.0
+ '@oxc-parser/binding-darwin-x64': 0.127.0
+ '@oxc-parser/binding-freebsd-x64': 0.127.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.127.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.127.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.127.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.127.0
+ '@oxc-parser/binding-linux-x64-musl': 0.127.0
+ '@oxc-parser/binding-openharmony-arm64': 0.127.0
+ '@oxc-parser/binding-wasm32-wasi': 0.127.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.127.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.127.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.127.0
+
+ oxc-resolver@11.21.2:
+ optionalDependencies:
+ '@oxc-resolver/binding-android-arm-eabi': 11.21.2
+ '@oxc-resolver/binding-android-arm64': 11.21.2
+ '@oxc-resolver/binding-darwin-arm64': 11.21.2
+ '@oxc-resolver/binding-darwin-x64': 11.21.2
+ '@oxc-resolver/binding-freebsd-x64': 11.21.2
+ '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2
+ '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2
+ '@oxc-resolver/binding-linux-arm64-gnu': 11.21.2
+ '@oxc-resolver/binding-linux-arm64-musl': 11.21.2
+ '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2
+ '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2
+ '@oxc-resolver/binding-linux-riscv64-musl': 11.21.2
+ '@oxc-resolver/binding-linux-s390x-gnu': 11.21.2
+ '@oxc-resolver/binding-linux-x64-gnu': 11.21.2
+ '@oxc-resolver/binding-linux-x64-musl': 11.21.2
+ '@oxc-resolver/binding-openharmony-arm64': 11.21.2
+ '@oxc-resolver/binding-wasm32-wasi': 11.21.2
+ '@oxc-resolver/binding-win32-arm64-msvc': 11.21.2
+ '@oxc-resolver/binding-win32-x64-msvc': 11.21.2
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -9343,7 +10873,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.28.6
+ '@babel/code-frame': 7.29.7
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -9372,6 +10902,8 @@ snapshots:
pathe@2.0.3: {}
+ pathval@2.0.1: {}
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -9382,6 +10914,14 @@ snapshots:
pirates@4.0.7: {}
+ playwright-core@1.64.1: {}
+
+ playwright@1.64.1:
+ dependencies:
+ playwright-core: 1.64.1
+
+ pngjs@7.0.0: {}
+
postcss-import@15.1.0(postcss@8.5.15):
dependencies:
postcss: 8.5.15
@@ -9429,6 +10969,13 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
+ probe-image-size@7.5.0:
+ dependencies:
+ lodash.merge: 4.6.2
+ needle: 2.9.1
+ transitivePeerDependencies:
+ - supports-color
+
progress@2.0.3: {}
proxy-from-env@1.1.0: {}
@@ -9441,6 +10988,25 @@ snapshots:
queue-microtask@1.2.3: {}
+ react-docgen-typescript@2.4.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ react-docgen@8.0.4:
+ dependencies:
+ '@babel/core': 7.29.6
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__core': 7.20.5
+ '@types/babel__traverse': 7.28.0
+ '@types/doctrine': 0.0.9
+ '@types/resolve': 1.20.6
+ doctrine: 3.0.0
+ resolve: 1.22.11
+ strip-indent: 4.1.1
+ transitivePeerDependencies:
+ - supports-color
+
react-dom@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
@@ -9505,6 +11071,19 @@ snapshots:
dependencies:
picomatch: 2.3.2
+ recast@0.23.21:
+ dependencies:
+ ast-types: 0.16.3
+ esprima: 4.0.1
+ source-map: 0.6.1
+ tiny-invariant: 1.3.3
+ tslib: 2.8.1
+
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
regenerate-unicode-properties@10.2.2:
dependencies:
regenerate: 1.4.2
@@ -9552,6 +11131,30 @@ snapshots:
reusify@1.1.0: {}
+ rolldown@1.0.0-rc.12(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0):
+ dependencies:
+ '@oxc-project/types': 0.122.0
+ '@rolldown/pluginutils': 1.0.0-rc.12
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.0-rc.12
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.12
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.12
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.12
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+
rolldown@1.0.2:
dependencies:
'@oxc-project/types': 0.132.0
@@ -9627,12 +11230,16 @@ snapshots:
rou3@0.8.1: {}
+ run-applescript@7.1.0: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
safe-buffer@5.2.1: {}
+ safer-buffer@2.1.2: {}
+
satori@0.16.0:
dependencies:
'@shuding/opentype.js': 1.4.0-beta.0
@@ -9716,6 +11323,12 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
@@ -9755,6 +11368,32 @@ snapshots:
std-env@4.1.0: {}
+ storybook@10.6.5(@types/react@19.2.15)(react@19.2.6):
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/icons': 2.2.0(react@19.2.6)
+ '@testing-library/dom': 10.4.1
+ '@testing-library/jest-dom': 6.9.1
+ '@testing-library/user-event': 14.6.8(@testing-library/dom@10.4.1)
+ '@vitest/expect': 3.2.4
+ '@vitest/spy': 3.2.4
+ '@webcontainer/env': 1.1.1
+ esbuild: 0.28.1
+ jsonc-parser: 3.3.1
+ open: 10.2.0
+ oxc-parser: 0.127.0
+ oxc-resolver: 11.21.2
+ recast: 0.23.21
+ semver: 7.8.4
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ ws: 8.22.1
+ optionalDependencies:
+ '@types/react': 19.2.15
+ transitivePeerDependencies:
+ - bufferutil
+ - react
+ - utf-8-validate
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -9783,6 +11422,14 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ strip-bom@3.0.0: {}
+
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
+ strip-indent@4.1.1: {}
+
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
@@ -9872,6 +11519,17 @@ snapshots:
tapable@2.3.3: {}
+ terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.15)(webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15)):
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ jest-worker: 27.5.1
+ schema-utils: 4.3.3
+ terser: 5.48.0
+ webpack: 5.104.1(esbuild@0.28.1)(postcss@8.5.15)
+ optionalDependencies:
+ esbuild: 0.28.1
+ postcss: 8.5.15
+
terser-webpack-plugin@5.6.1(esbuild@0.28.1)(webpack@5.104.1(esbuild@0.28.1)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -9911,6 +11569,8 @@ snapshots:
tiny-inflate@1.0.3: {}
+ tiny-invariant@1.3.3: {}
+
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -9925,8 +11585,12 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinyrainbow@2.0.0: {}
+
tinyrainbow@3.1.0: {}
+ tinyspy@4.0.6: {}
+
tldts-core@7.0.21: {}
tldts@7.0.21:
@@ -9949,12 +11613,20 @@ snapshots:
dependencies:
punycode: 2.3.1
+ ts-dedent@2.3.0: {}
+
ts-interface-checker@0.1.13: {}
tsconfck@3.1.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
+ tsconfig-paths@4.2.0:
+ dependencies:
+ json5: 2.2.3
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
tslib@2.8.1: {}
tsx@4.21.0:
@@ -10006,6 +11678,13 @@ snapshots:
unpic@4.2.2: {}
+ unplugin@2.3.11:
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ acorn: 8.17.0
+ picomatch: 4.0.4
+ webpack-virtual-modules: 0.6.2
+
unstorage@2.0.0-alpha.7(chokidar@3.6.0)(db0@0.3.4)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3):
optionalDependencies:
chokidar: 3.6.0
@@ -10042,13 +11721,12 @@ snapshots:
use-sync-external-store@1.6.0(react@19.2.6):
dependencies:
react: 19.2.6
- optional: true
util-deprecate@1.0.2: {}
- vinext@0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(next@16.2.9(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(typescript@5.9.3)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
+ vinext@0.0.51(@vitejs/plugin-react@6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.26(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)))(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(webpack@5.104.1(esbuild@0.28.1)))(react@19.2.6)(typescript@5.9.3)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
- '@unpic/react': 1.0.2(next@16.2.9(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@unpic/react': 1.0.2(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@vercel/og': 0.8.6
'@vitejs/plugin-react': 6.0.2(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
image-size: 2.0.2
@@ -10081,6 +11759,47 @@ snapshots:
fast-glob: 3.3.3
magic-string: 0.30.21
+ vite-plugin-storybook-nextjs@10.6.5(next@16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(rollup@4.62.2)(storybook@10.6.5(@types/react@19.2.15)(react@19.2.6))(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
+ dependencies:
+ '@next/env': 16.2.9
+ '@rollup/pluginutils': 5.3.0(rollup@4.62.2)
+ magic-string: 0.30.21
+ module-alias: 2.3.5
+ next: 16.2.9(@babel/core@7.29.6)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ pathe: 2.0.3
+ probe-image-size: 7.5.0
+ semver: 7.8.4
+ storybook: 10.6.5(@types/react@19.2.15)(react@19.2.6)
+ ts-dedent: 2.3.0
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ transitivePeerDependencies:
+ - rollup
+ - supports-color
+ - typescript
+
+ vite-plugin-svgr@4.5.0(rollup@4.62.2)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
+ dependencies:
+ '@rollup/pluginutils': 5.3.0(rollup@4.62.2)
+ '@svgr/core': 8.1.0(typescript@5.9.3)
+ '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ transitivePeerDependencies:
+ - rollup
+ - supports-color
+ - typescript
+
+ vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
+ dependencies:
+ debug: 4.4.3
+ globrex: 0.1.2
+ tsconfck: 3.1.6(typescript@5.9.3)
+ optionalDependencies:
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3
@@ -10091,6 +11810,25 @@ snapshots:
- supports-color
- typescript
+ vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rolldown: 1.0.0-rc.12(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 20.19.30
+ esbuild: 0.28.1
+ fsevents: 2.3.3
+ jiti: 1.21.7
+ terser: 5.48.0
+ tsx: 4.21.0
+ yaml: 2.8.3
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+
vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -10111,7 +11849,37 @@ snapshots:
optionalDependencies:
vite: 8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
- vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
+ vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
+ dependencies:
+ '@vitest/expect': 4.1.9
+ '@vitest/mocker': 4.1.9(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/pretty-format': 4.1.9
+ '@vitest/runner': 4.1.9
+ '@vitest/snapshot': 4.1.9
+ '@vitest/spy': 4.1.9
+ '@vitest/utils': 4.1.9
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.1
+ '@types/node': 20.19.30
+ '@vitest/browser-playwright': 4.1.9(playwright@1.64.1)(vite@8.0.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)(@types/node@20.19.30)(esbuild@0.28.1)(jiti@1.21.7)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9)
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
+ vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))
@@ -10136,6 +11904,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 22.19.7
+ '@vitest/browser-playwright': 4.1.9(playwright@1.64.1)(vite@8.1.0(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.9)
jsdom: 29.1.1
transitivePeerDependencies:
- msw
@@ -10179,6 +11948,8 @@ snapshots:
webpack-sources@3.5.0: {}
+ webpack-virtual-modules@0.6.2: {}
+
webpack@5.104.1(esbuild@0.28.1):
dependencies:
'@types/eslint-scope': 3.7.7
@@ -10220,6 +11991,47 @@ snapshots:
- postcss
- uglify-js
+ webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15):
+ dependencies:
+ '@types/eslint-scope': 3.7.7
+ '@types/estree': 1.0.9
+ '@types/json-schema': 7.0.15
+ '@webassemblyjs/ast': 1.14.1
+ '@webassemblyjs/wasm-edit': 1.14.1
+ '@webassemblyjs/wasm-parser': 1.14.1
+ acorn: 8.17.0
+ acorn-import-phases: 1.0.4(acorn@8.17.0)
+ browserslist: 4.28.2
+ chrome-trace-event: 1.0.4
+ enhanced-resolve: 5.24.0
+ es-module-lexer: 2.1.0
+ eslint-scope: 5.1.1
+ events: 3.3.0
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+ json-parse-even-better-errors: 2.3.1
+ loader-runner: 4.3.2
+ mime-types: 2.1.35
+ neo-async: 2.6.2
+ schema-utils: 4.3.3
+ tapable: 2.3.3
+ terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.15)(webpack@5.104.1(esbuild@0.28.1)(postcss@8.5.15))
+ watchpack: 2.5.2
+ webpack-sources: 3.5.0
+ transitivePeerDependencies:
+ - '@minify-html/node'
+ - '@swc/core'
+ - '@swc/css'
+ - '@swc/html'
+ - clean-css
+ - cssnano
+ - csso
+ - esbuild
+ - html-minifier-terser
+ - lightningcss
+ - postcss
+ - uglify-js
+
webpack@5.104.1(postcss@8.5.15):
dependencies:
'@types/eslint-scope': 3.7.7
@@ -10313,6 +12125,14 @@ snapshots:
ws@7.5.11: {}
+ ws@8.21.0: {}
+
+ ws@8.22.1: {}
+
+ wsl-utils@0.1.0:
+ dependencies:
+ is-wsl: 3.1.1
+
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}