diff --git a/__tests__/dark-mode-switcher.test.tsx b/__tests__/dark-mode-switcher.test.tsx
new file mode 100644
index 0000000..22b0e9c
--- /dev/null
+++ b/__tests__/dark-mode-switcher.test.tsx
@@ -0,0 +1,248 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import DarkModeSwitcher, {
+ DarkModeSwitcherEmptyState,
+ type DarkModeSwitcherProps,
+} from "@/app/components/DarkModeSwitcher";
+
+// ---------------------------------------------------------------------------
+// Helper
+// ---------------------------------------------------------------------------
+
+function renderSwitcher(props: DarkModeSwitcherProps = {}) {
+ return render( );
+}
+
+// ---------------------------------------------------------------------------
+// Empty state (isDarkMode is null / undefined)
+// ---------------------------------------------------------------------------
+
+describe("DarkModeSwitcher — empty state", () => {
+ it("renders empty state when isDarkMode is undefined", () => {
+ renderSwitcher();
+ expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument();
+ });
+
+ it("renders empty state when isDarkMode is null", () => {
+ renderSwitcher({ isDarkMode: null });
+ expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument();
+ });
+
+ it("does NOT render the toggle button in empty state", () => {
+ renderSwitcher();
+ expect(screen.queryByTestId("dark-mode-switcher")).not.toBeInTheDocument();
+ });
+
+ it("empty state has role=region", () => {
+ renderSwitcher();
+ expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute(
+ "role",
+ "region"
+ );
+ });
+
+ it("empty state has descriptive aria-label", () => {
+ renderSwitcher();
+ expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute(
+ "aria-label",
+ "No theme preferences"
+ );
+ });
+
+ it("DarkModeSwitcherEmptyState renders standalone", () => {
+ render( );
+ expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument();
+ });
+
+ it("DarkModeSwitcherEmptyState accepts className", () => {
+ render( );
+ expect(screen.getByTestId("dark-mode-switcher-empty-state").className).toContain("mt-4");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Loading state
+// ---------------------------------------------------------------------------
+
+describe("DarkModeSwitcher — loading state", () => {
+ it("renders loading state when loading=true", () => {
+ renderSwitcher({ loading: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument();
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "loading");
+ });
+
+ it("loading state has role=status", () => {
+ renderSwitcher({ loading: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("role", "status");
+ });
+
+ it("loading state has aria-label 'Loading theme'", () => {
+ renderSwitcher({ loading: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute(
+ "aria-label",
+ "Loading theme"
+ );
+ });
+
+ it("loading state shows loading text", () => {
+ renderSwitcher({ loading: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveTextContent("Loading theme...");
+ });
+
+ it("loading overrides the empty state — renders switcher not empty state", () => {
+ renderSwitcher({ loading: true, isDarkMode: undefined });
+ expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument();
+ expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Toggle — dark mode on
+// ---------------------------------------------------------------------------
+
+describe("DarkModeSwitcher — dark mode active", () => {
+ it("renders the toggle button", () => {
+ renderSwitcher({ isDarkMode: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument();
+ });
+
+ it("has role=switch", () => {
+ renderSwitcher({ isDarkMode: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("role", "switch");
+ });
+
+ it("has aria-checked=true when dark", () => {
+ renderSwitcher({ isDarkMode: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("has data-state=dark", () => {
+ renderSwitcher({ isDarkMode: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "dark");
+ });
+
+ it("defaults aria-label to 'Switch to light mode' when dark", () => {
+ renderSwitcher({ isDarkMode: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute(
+ "aria-label",
+ "Switch to light mode"
+ );
+ });
+
+ it("thumb is translated right when dark", () => {
+ renderSwitcher({ isDarkMode: true });
+ expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveClass("translate-x-5");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Toggle — light mode
+// ---------------------------------------------------------------------------
+
+describe("DarkModeSwitcher — light mode active", () => {
+ it("has aria-checked=false when light", () => {
+ renderSwitcher({ isDarkMode: false });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("has data-state=light", () => {
+ renderSwitcher({ isDarkMode: false });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "light");
+ });
+
+ it("defaults aria-label to 'Switch to dark mode' when light", () => {
+ renderSwitcher({ isDarkMode: false });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute(
+ "aria-label",
+ "Switch to dark mode"
+ );
+ });
+
+ it("thumb is at translate-x-0 when light", () => {
+ renderSwitcher({ isDarkMode: false });
+ expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveClass("translate-x-0");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Interactions
+// ---------------------------------------------------------------------------
+
+describe("DarkModeSwitcher — interactions", () => {
+ it("calls onToggle on click", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ renderSwitcher({ isDarkMode: true, onToggle });
+ await user.click(screen.getByTestId("dark-mode-switcher"));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onToggle on Space key", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ renderSwitcher({ isDarkMode: false, onToggle });
+ screen.getByTestId("dark-mode-switcher").focus();
+ await user.keyboard(" ");
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onToggle on Enter key", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ renderSwitcher({ isDarkMode: false, onToggle });
+ screen.getByTestId("dark-mode-switcher").focus();
+ await user.keyboard("{Enter}");
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("does NOT call onToggle when disabled", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ renderSwitcher({ isDarkMode: true, onToggle, disabled: true });
+ await user.click(screen.getByTestId("dark-mode-switcher"));
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+
+ it("does NOT call onToggle when loading", async () => {
+ const onToggle = vi.fn();
+ const user = userEvent.setup();
+ renderSwitcher({ loading: true, onToggle });
+ // loading state is a span, not a button — no click interaction
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+describe("DarkModeSwitcher — props", () => {
+ it("accepts a custom ariaLabel", () => {
+ renderSwitcher({ isDarkMode: true, ariaLabel: "Toggle theme" });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute(
+ "aria-label",
+ "Toggle theme"
+ );
+ });
+
+ it("accepts a custom id", () => {
+ renderSwitcher({ isDarkMode: false, id: "my-switcher" });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("id", "my-switcher");
+ });
+
+ it("applies custom className to the button", () => {
+ renderSwitcher({ isDarkMode: false, className: "mt-4" });
+ expect(screen.getByTestId("dark-mode-switcher").className).toContain("mt-4");
+ });
+
+ it("disabled button has aria-disabled=true", () => {
+ renderSwitcher({ isDarkMode: true, disabled: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("disabled button has tabIndex=-1", () => {
+ renderSwitcher({ isDarkMode: false, disabled: true });
+ expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("tabindex", "-1");
+ });
+});
diff --git a/__tests__/dashboard-accessibility.test.tsx b/__tests__/dashboard-accessibility.test.tsx
index 20e4ce9..f63b541 100644
--- a/__tests__/dashboard-accessibility.test.tsx
+++ b/__tests__/dashboard-accessibility.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Dashboard from "@/app/dashboard/page";
@@ -374,15 +374,17 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML)
render( );
- await waitFor(() => {
- const expandButton = screen.getByRole("button", { name: /Job #job-1/i });
- expandButton.click();
+ const expandButton = await screen.findByRole("button", {
+ name: /Job #job-1/i,
+ });
+ if (expandButton.getAttribute("aria-expanded") !== "true") {
+ fireEvent.click(expandButton);
+ }
- waitFor(() => {
- const region = screen.getByRole("region", { name: /Details for job #job-1/i });
- expect(region).toBeInTheDocument();
- });
+ const region = await screen.findByRole("region", {
+ name: /Details for job #job-1/i,
});
+ expect(region).toBeInTheDocument();
});
it("role badges have aria-label describing role", async () => {
@@ -467,11 +469,9 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML)
render( );
- waitFor(() => {
- const status = screen.getByRole("status");
- expect(status).toHaveAttribute("aria-live", "polite");
- expect(status).toHaveTextContent("Connect your wallet");
- });
+ const status = screen.getByRole("status");
+ expect(status).toHaveAttribute("aria-live", "polite");
+ expect(status).toHaveTextContent("Connect your wallet");
});
});
@@ -783,15 +783,17 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML)
render( );
- await waitFor(() => {
- const expandButton = screen.getByRole("button", { name: /Job #job-1/i });
- expandButton.click();
+ const expandButton = await screen.findByRole("button", {
+ name: /Job #job-1/i,
+ });
+ if (expandButton.getAttribute("aria-expanded") !== "true") {
+ fireEvent.click(expandButton);
+ }
- waitFor(() => {
- const milestonesRegion = screen.getByRole("region", { name: "Milestones" });
- expect(milestonesRegion).toBeInTheDocument();
- });
+ const milestonesRegion = await screen.findByRole("region", {
+ name: "Milestones",
});
+ expect(milestonesRegion).toBeInTheDocument();
});
});
@@ -1095,14 +1097,18 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML)
render( );
- await waitFor(() => {
- const expandButton = screen.getByRole("button", { name: /Job #job-1/i });
- expandButton.click();
+ const expandButton = await screen.findByRole("button", {
+ name: /Job #job-1/i,
+ });
+ if (expandButton.getAttribute("aria-expanded") !== "true") {
+ fireEvent.click(expandButton);
+ }
- waitFor(() => {
- const decorativeText = screen.queryByText("Collapse", { selector: "[aria-hidden='true']" });
- expect(decorativeText).toBeInTheDocument();
+ await waitFor(() => {
+ const decorativeText = screen.queryByText("Collapse", {
+ selector: "[aria-hidden='true']",
});
+ expect(decorativeText).toBeInTheDocument();
});
});
@@ -1114,10 +1120,8 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML)
render( );
- waitFor(() => {
- const status = screen.getByRole("status");
- expect(status).toHaveAttribute("aria-live", "polite");
- });
+ const status = screen.getByRole("status");
+ expect(status).toHaveAttribute("aria-live", "polite");
});
it("role=alert exposes error messages to screen readers immediately", async () => {
diff --git a/__tests__/dashboard-list.test.tsx b/__tests__/dashboard-list.test.tsx
new file mode 100644
index 0000000..fec3e5d
--- /dev/null
+++ b/__tests__/dashboard-list.test.tsx
@@ -0,0 +1,425 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Dashboard from "@/app/dashboard/page";
+
+const mockUseWallet = vi.fn();
+const mockUseToast = vi.fn();
+const mockUseActionStates = vi.fn();
+
+vi.mock("@/app/context/WalletContext", () => ({
+ useWallet: () => mockUseWallet(),
+}));
+
+vi.mock("@/app/context/ToastContext", () => ({
+ useToast: () => mockUseToast(),
+}));
+
+vi.mock("@/app/components/Navbar", () => ({
+ default: () =>
,
+}));
+
+vi.mock("@/app/components/LoadingSkeleton", () => ({
+ default: () =>
,
+}));
+
+vi.mock("@/app/components/MilestoneCard", () => ({
+ default: () =>
,
+}));
+
+vi.mock("@/app/components/EmptyStateCard", () => ({
+ default: ({
+ testId,
+ ariaLabel,
+ title,
+ description,
+ icon,
+ badges,
+ }: {
+ testId?: string;
+ ariaLabel?: string;
+ title: string;
+ description: string;
+ icon?: string;
+ badges?: string[];
+ }) => (
+
+
+ {icon}
+
+
{title}
+
{description}
+
+ {badges?.map((badge) => (
+
+ {badge}
+
+ ))}
+
+
+ ),
+}));
+
+vi.mock("@/app/hooks/useActionStates", () => ({
+ useActionStates: () => mockUseActionStates(),
+}));
+
+describe("Dashboard — jobs list (dashboard_list) rendering", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseWallet.mockReturnValue({
+ address: "GCLIENTADDRESS1234567890123456789012345678901234",
+ signTransaction: vi.fn(),
+ });
+ mockUseToast.mockReturnValue({
+ showToast: vi.fn(),
+ toasts: [],
+ hideToast: vi.fn(),
+ });
+ mockUseActionStates.mockReturnValue({
+ getState: () => ({ phase: "idle", error: null, txHash: null }),
+ isPending: () => false,
+ setPhase: vi.fn(),
+ setError: vi.fn(),
+ setTxHash: vi.fn(),
+ });
+ });
+
+ type MockJob = ReturnType;
+
+ const mockJobsResponse = (
+ jobs: MockJob[] = [],
+ overrides: Record = {}
+ ) => ({
+ success: true,
+ data: jobs,
+ page: 1,
+ limit: 5,
+ total: jobs.length,
+ ...overrides,
+ });
+
+ const mockJob = (overrides: Record = {}) => ({
+ id: "job-1234567890abcdef",
+ client: "GCLIENTADDRESS1234567890123456789012345678901234",
+ freelancer: "GFREELANCERADDRESS12345678901234567890123456",
+ arbiter: "GARBITERADDRESS12345678901234567890123456789012",
+ funded: true,
+ milestones: [
+ { index: 0, amount: "250000000", status: "Pending" },
+ { index: 1, amount: "500000000", status: "Delivered" },
+ ],
+ tokenSymbol: "USDC",
+ tokenDecimals: 7,
+ ...overrides,
+ });
+
+ const setupFetch = (response: unknown) => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ json: async () => response,
+ }));
+ };
+
+ describe("Loading state", () => {
+ it("shows LoadingSkeleton while fetching jobs", async () => {
+ let resolveFetch!: (value: unknown) => void;
+ const fetchPromise = new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+ vi.stubGlobal("fetch", vi.fn().mockReturnValue(fetchPromise));
+
+ render( );
+
+ expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument();
+
+ await resolveFetch(mockJobsResponse([]));
+ });
+
+ it("shows loading skeleton with proper accessible status role", () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ json: async () => mockJobsResponse([]),
+ }));
+
+ render( );
+
+ const skeleton = screen.getByTestId("loading-skeleton");
+ expect(skeleton).toBeInTheDocument();
+ });
+ });
+
+ describe("Error state", () => {
+ it("displays error alert when API fails", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ json: async () => ({
+ success: false,
+ error: "Failed to fetch jobs",
+ }),
+ }));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("dashboard-error-alert")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Error loading jobs")).toBeInTheDocument();
+ expect(screen.getByText("Failed to fetch jobs")).toBeInTheDocument();
+ });
+
+ it("error alert has proper ARIA attributes", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ json: async () => ({
+ success: false,
+ error: "Backend unavailable",
+ }),
+ }));
+
+ render( );
+
+ await waitFor(() => {
+ const alert = screen.getByTestId("dashboard-error-alert");
+ expect(alert).toHaveAttribute("role", "alert");
+ expect(alert).toHaveAttribute("aria-live", "assertive");
+ });
+ });
+ });
+
+ describe("Empty state", () => {
+ it("shows EmptyStateCard when no jobs found", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("dashboard-empty-state")).toBeInTheDocument();
+ });
+ });
+
+ it("displays correct title and description in empty state", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("No jobs found")).toBeInTheDocument();
+ expect(screen.getByText(/You don't have any jobs yet/)).toBeInTheDocument();
+ });
+ });
+
+ it("shows role badges in empty state", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Client")).toBeInTheDocument();
+ expect(screen.getByText("Freelancer")).toBeInTheDocument();
+ expect(screen.getByText("Arbiter")).toBeInTheDocument();
+ });
+ });
+
+ it("empty state is an accessible region", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByRole("region", { name: "No jobs" })).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Jobs list rendering", () => {
+ it("renders job list items when jobs exist", async () => {
+ const jobs = [
+ mockJob({ id: "job-1", funded: true }),
+ mockJob({ id: "job-2", funded: false }),
+ ];
+ setupFetch(mockJobsResponse(jobs));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId("dashboard-list-item")).toHaveLength(2);
+ });
+ });
+
+ it("displays job ID prefix for each job", async () => {
+ const jobs = [mockJob({ id: "job-abcdef123456" })];
+ setupFetch(mockJobsResponse(jobs));
+
+ render( );
+
+ await waitFor(() => {
+ // Component slices first 8 chars: "job-abcd"
+ expect(screen.getByText("Job #job-abcd")).toBeInTheDocument();
+ });
+ });
+
+ it("shows funded status for each job", async () => {
+ const jobs = [
+ mockJob({ id: "job-funded", funded: true }),
+ mockJob({ id: "job-unfunded", funded: false }),
+ ];
+ setupFetch(mockJobsResponse(jobs));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Funded")).toBeInTheDocument();
+ expect(screen.getByText("Not funded")).toBeInTheDocument();
+ });
+ });
+
+ it("displays role badges for user's roles", async () => {
+ const jobs = [mockJob({
+ id: "job-with-roles",
+ client: "GCLIENTADDRESS1234567890123456789012345678901234",
+ freelancer: "GFREELANCERADDRESS12345678901234567890123456",
+ })];
+ setupFetch(mockJobsResponse(jobs));
+
+ render( );
+
+ await waitFor(() => {
+ // Check role badges in the list item - they appear as text
+ const clientBadges = screen.getAllByText("Client");
+ const freelancerBadges = screen.getAllByText("Freelancer");
+ expect(clientBadges.length).toBeGreaterThanOrEqual(1);
+ expect(freelancerBadges.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("job list container has proper ARIA label", async () => {
+ const jobs = [mockJob({ id: "job-1" })];
+ setupFetch(mockJobsResponse(jobs));
+
+ render( );
+
+ await waitFor(() => {
+ const listRegion = screen.getByRole("region", { name: "Jobs list" });
+ expect(listRegion).toBeInTheDocument();
+ });
+ });
+
+ it("job items have expand/collapse button with proper ARIA", async () => {
+ const jobs = [mockJob({ id: "job-expandable" })];
+ setupFetch(mockJobsResponse(jobs));
+
+ render( );
+
+ await waitFor(() => {
+ const button = screen.getByRole("button", { name: /Job #job-exp/ });
+ expect(button).toBeInTheDocument();
+ // First job is expanded by default, so aria-expanded is "true"
+ expect(button).toHaveAttribute("aria-expanded", "true");
+ expect(button).toHaveAttribute("aria-controls");
+ });
+ });
+ });
+
+ describe("Search and filter", () => {
+ it("has search input with proper ARIA attributes", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ const searchInput = screen.getByLabelText("Search by contract ID");
+ expect(searchInput).toBeInTheDocument();
+ expect(searchInput).toHaveAttribute("placeholder", "Search by contract/job ID");
+ });
+ });
+
+ it("has role filter tabs with proper ARIA", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ const filterTabs = screen.getByRole("tablist", { name: "Filter jobs by role" });
+ expect(filterTabs).toBeInTheDocument();
+
+ expect(screen.getByRole("tab", { name: "Filter jobs: All" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Filter jobs: As Client" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Filter jobs: As Freelancer" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Filter jobs: As Arbiter" })).toBeInTheDocument();
+ });
+ });
+
+ it("role filter shows active state", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ const allTab = screen.getByRole("tab", { name: "Filter jobs: All" });
+ expect(allTab).toHaveAttribute("aria-selected", "true");
+ });
+ });
+ });
+
+ describe("Disconnected wallet state", () => {
+ it("shows connect wallet message when no address", async () => {
+ mockUseWallet.mockReturnValue({
+ address: null,
+ signTransaction: vi.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByText(/Connect your wallet to view your jobs/)).toBeInTheDocument();
+ });
+
+ it("connect wallet message has proper ARIA attributes", async () => {
+ mockUseWallet.mockReturnValue({
+ address: null,
+ signTransaction: vi.fn(),
+ });
+
+ render( );
+
+ const message = screen.getByText(/Connect your wallet to view your jobs/);
+ expect(message).toHaveAttribute("role", "status");
+ expect(message).toHaveAttribute("aria-live", "polite");
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("maintains proper heading hierarchy", async () => {
+ setupFetch(mockJobsResponse([]));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { level: 1, name: "Job Dashboard" })).toBeInTheDocument();
+ });
+ });
+ });
+});
+
+function mockJob(overrides = {}) {
+ return {
+ id: "job-1234567890abcdef",
+ client: "GCLIENTADDRESS1234567890123456789012345678901234",
+ freelancer: "GFREELANCERADDRESS12345678901234567890123456",
+ arbiter: "GARBITERADDRESS12345678901234567890123456789012",
+ funded: true,
+ milestones: [
+ { index: 0, amount: "250000000", status: "Pending" },
+ { index: 1, amount: "500000000", status: "Delivered" },
+ ],
+ tokenSymbol: "USDC",
+ tokenDecimals: 7,
+ ...overrides,
+ };
+}
\ No newline at end of file
diff --git a/__tests__/loading-skeleton-a11y.test.tsx b/__tests__/loading-skeleton-a11y.test.tsx
new file mode 100644
index 0000000..1c84d7d
--- /dev/null
+++ b/__tests__/loading-skeleton-a11y.test.tsx
@@ -0,0 +1,111 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import LoadingSkeleton from "@/app/components/LoadingSkeleton";
+
+// ===========================================================================
+// LoadingSkeleton — ARIA attribute compliance (issue #273)
+// ===========================================================================
+
+describe("LoadingSkeleton — ARIA loading semantics", () => {
+ it("advertises the busy/loading state via aria-busy on the status node", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
+ });
+
+ it("keeps aria-live='polite' on the default (non-interactive) variant", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
+ });
+
+ it("does not apply aria-live to the interactive button variant", () => {
+ render( );
+ const button = screen.getByRole("button");
+ expect(button).not.toHaveAttribute("aria-live");
+ expect(button).toHaveAttribute("aria-busy", "true");
+ });
+
+ it("exposes the accessible loading message as sr-only content", () => {
+ render( );
+ const message = screen.getByText("Loading job data…");
+ expect(message).toHaveClass("sr-only");
+ });
+
+ it("hides the visual skeleton placeholder from assistive technology", () => {
+ const { container } = render( );
+ const card = container.querySelector('[aria-hidden="true"]');
+ expect(card).toBeInTheDocument();
+ });
+});
+
+describe("LoadingSkeleton — reduced motion compliance", () => {
+ it("disables the pulse animation under prefers-reduced-motion", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveClass("motion-reduce:animate-none");
+ });
+
+ it("disables fade-in transitions under prefers-reduced-motion", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveClass("motion-reduce:transition-none");
+ });
+
+ it("still renders the default, accessible pulse animation class", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveClass("animate-pulse");
+ });
+});
+
+describe("LoadingSkeleton — keyboard navigability", () => {
+ it("is focusable when interactive", () => {
+ render( );
+ const button = screen.getByRole("button");
+ expect(button).toHaveAttribute("tabindex", "0");
+ });
+
+ it("is removed from the tab order when disabled", () => {
+ render( );
+ const button = screen.getByRole("button", { hidden: true });
+ expect(button).toHaveAttribute("tabindex", "-1");
+ });
+
+ it("responds to Enter and Space keys when interactive", () => {
+ const handleClick = vi.fn();
+ render( );
+ const button = screen.getByRole("button");
+ fireEvent.keyDown(button, { key: "Enter", code: "Enter" });
+ fireEvent.keyDown(button, { key: " ", code: "Space" });
+ expect(handleClick).toHaveBeenCalledTimes(2);
+ });
+
+ it("exposes a visible focus ring for the interactive variant", () => {
+ render( );
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("focus-visible:ring-2");
+ expect(button).toHaveClass("focus-visible:ring-blue-500");
+ });
+});
+
+describe("LoadingSkeleton — interactive accessible name", () => {
+ it("accepts a custom aria-label for the interactive variant", () => {
+ render(
+
+ );
+ const button = screen.getByRole("button", { name: "Retry loading" });
+ expect(button).toBeInTheDocument();
+ });
+
+ it("falls back to the sr-only text as the accessible name when no label is set", () => {
+ render( );
+ // Accessible name is computed from the sr-only text content.
+ expect(
+ screen.getByRole("button", { name: "Loading job data…" })
+ ).toBeInTheDocument();
+ });
+});
+
+describe("LoadingSkeleton — color contrast tokens", () => {
+ it("uses the dark surface background token for contrast", () => {
+ const { container } = render( );
+ const card = container.querySelector('[aria-hidden="true"]');
+ expect(card).toHaveClass("bg-surface-card");
+ });
+});
diff --git a/__tests__/loading-skeleton-validation.test.tsx b/__tests__/loading-skeleton-validation.test.tsx
new file mode 100644
index 0000000..fc35a8b
--- /dev/null
+++ b/__tests__/loading-skeleton-validation.test.tsx
@@ -0,0 +1,112 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import LoadingSkeleton from "@/app/components/LoadingSkeleton";
+
+// ===========================================================================
+// LoadingSkeleton — validation messages & alerts (issue #277)
+// ===========================================================================
+
+describe("LoadingSkeleton — single validation error", () => {
+ it("renders an accessible alert when an error message is provided", () => {
+ render( );
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(screen.getByText("Job budget is invalid")).toBeInTheDocument();
+ });
+
+ it("renders the alert with a live assertive region", () => {
+ render( );
+ expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive");
+ });
+
+ it("renders no alert when no error is provided", () => {
+ render( );
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("links the skeleton to the alerts via aria-describedby", () => {
+ render( );
+ const status = screen.getByRole("status");
+ expect(status).toHaveAttribute("aria-describedby", "loading-skeleton-errors");
+ expect(screen.getByTestId("loading-skeleton-errors")).toHaveAttribute(
+ "id",
+ "loading-skeleton-errors"
+ );
+ });
+
+ it("does not set aria-describedby when there are no alerts", () => {
+ render( );
+ expect(screen.getByRole("status")).not.toHaveAttribute("aria-describedby");
+ });
+});
+
+describe("LoadingSkeleton — multiple validation errors (object keyed by field)", () => {
+ const errors = {
+ title: "Title is required",
+ budget: "Budget must be a positive number",
+ };
+
+ it("renders one alert per validation error", () => {
+ render( );
+ expect(screen.getAllByRole("alert")).toHaveLength(2);
+ });
+
+ it("prefixes each message with its field name", () => {
+ render( );
+ expect(screen.getByText("title:")).toBeInTheDocument();
+ expect(screen.getByText("budget:")).toBeInTheDocument();
+ expect(screen.getByText("Title is required")).toBeInTheDocument();
+ expect(screen.getByText("Budget must be a positive number")).toBeInTheDocument();
+ });
+
+ it("renders every field message value", () => {
+ render( );
+ expect(screen.getAllByRole("alert")).toHaveLength(2);
+ });
+});
+
+describe("LoadingSkeleton — multiple validation errors (array form)", () => {
+ const errors = [
+ { field: "escrowAgent", message: "Escrow agent is required" },
+ { message: "Unexpected failure" },
+ ];
+
+ it("renders one alert per array entry", () => {
+ render( );
+ expect(screen.getAllByRole("alert")).toHaveLength(2);
+ });
+
+ it("renders the field label only when a field is provided", () => {
+ render( );
+ expect(screen.getByText("escrowAgent:")).toBeInTheDocument();
+ expect(screen.queryByText("Unexpected failure:")).not.toBeInTheDocument();
+ expect(screen.getByText("Unexpected failure")).toBeInTheDocument();
+ });
+
+ it("applies high-contrast error styling to alert content", () => {
+ render( );
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveClass("text-red-400");
+ expect(alert).toHaveClass("bg-red-950/40");
+ expect(alert).toHaveClass("border-red-800");
+ });
+});
+
+describe("LoadingSkeleton — empty collection of errors", () => {
+ it("renders no alerts for an empty object", () => {
+ render( );
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("renders no alerts for a null error prop", () => {
+ render( );
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+});
+
+describe("LoadingSkeleton — alerts remain accessible in interactive mode", () => {
+ it("renders alerts alongside the interactive button", () => {
+ render( );
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/rabe_connector.test.ts b/__tests__/rabe_connector.test.ts
index 46adcd0..689a77a 100644
--- a/__tests__/rabe_connector.test.ts
+++ b/__tests__/rabe_connector.test.ts
@@ -1,12 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
checkRabeNetworkMatch,
+ clearRabeAddressCache,
+ deserializeRabeAddressCache,
formatConsoleWarningBlock,
formatStackTrace,
+ loadRabeAddressCache,
logRabeWarning,
+ RABE_CACHE_KEY,
+ RabeActiveAddressCache,
RabeNetworkMismatchError,
RabeTransactionTracker,
rabeTracker,
+ saveRabeAddressCache,
+ serializeRabeAddressCache,
+ validateRabeAddressCache,
warnOnRabeNetworkMismatch,
} from "@/app/lib/rabe_connector";
@@ -248,3 +256,259 @@ describe("rabe_connector network mismatch checks", () => {
expect(warnSpy).not.toHaveBeenCalled();
});
});
+
+// ---------------------------------------------------------------------------
+// Persistent address-cache tests
+// ---------------------------------------------------------------------------
+
+/** A valid Stellar public key (56 chars, starts with G, Base32 A-Z2-7). */
+const VALID_ADDRESS = "GD4TI4BA2F6L3UE2SFNFEM5DBGIP2MYH7ID2KHDI2HF3YEEGCFB2OOAI";
+const VALID_NETWORK = "testnet" as const;
+
+function makeCache(
+ overrides: Partial = {}
+): RabeActiveAddressCache {
+ return {
+ version: 1,
+ address: VALID_ADDRESS,
+ savedAt: Date.now(),
+ network: VALID_NETWORK,
+ ...overrides,
+ };
+}
+
+describe("rabe_connector active-address cache — validateRabeAddressCache", () => {
+ it("accepts a fully-valid cache object", () => {
+ expect(validateRabeAddressCache(makeCache())).toBe(true);
+ });
+
+ it("accepts mainnet as a valid network", () => {
+ expect(validateRabeAddressCache(makeCache({ network: "mainnet" }))).toBe(
+ true
+ );
+ });
+
+ it("rejects null", () => {
+ expect(validateRabeAddressCache(null)).toBe(false);
+ });
+
+ it("rejects a non-object primitive", () => {
+ expect(validateRabeAddressCache("string")).toBe(false);
+ expect(validateRabeAddressCache(42)).toBe(false);
+ });
+
+ it("rejects an empty object", () => {
+ expect(validateRabeAddressCache({})).toBe(false);
+ });
+
+ it("rejects wrong version number", () => {
+ expect(validateRabeAddressCache({ ...makeCache(), version: 2 })).toBe(
+ false
+ );
+ });
+
+ it("rejects a missing address field", () => {
+ const { address: _a, ...rest } = makeCache();
+ expect(validateRabeAddressCache(rest)).toBe(false);
+ });
+
+ it("rejects an address that does not start with G", () => {
+ expect(
+ validateRabeAddressCache(makeCache({ address: "XD4TI4BA2F6L3UE2SFNFEM5DBGIP2MYH7ID2KHDI2HF3YEEGCFB2OOAI" }))
+ ).toBe(false);
+ });
+
+ it("rejects an address that is too short", () => {
+ expect(
+ validateRabeAddressCache(makeCache({ address: "GABC" }))
+ ).toBe(false);
+ });
+
+ it("rejects an address with invalid Base32 characters", () => {
+ // Contains '0' which is not in Base32 alphabet A-Z2-7
+ expect(
+ validateRabeAddressCache(makeCache({ address: "G04TI4BA2F6L3UE2SFNFEM5DBGIP2MYH7ID2KHDI2HF3YEEGCFB2OOAI" }))
+ ).toBe(false);
+ });
+
+ it("rejects a non-numeric savedAt", () => {
+ expect(
+ validateRabeAddressCache({ ...makeCache(), savedAt: "now" })
+ ).toBe(false);
+ });
+
+ it("rejects a zero savedAt", () => {
+ expect(validateRabeAddressCache(makeCache({ savedAt: 0 }))).toBe(false);
+ });
+
+ it("rejects a negative savedAt", () => {
+ expect(validateRabeAddressCache(makeCache({ savedAt: -1 }))).toBe(false);
+ });
+
+ it("rejects an unknown network string", () => {
+ expect(
+ validateRabeAddressCache({ ...makeCache(), network: "devnet" })
+ ).toBe(false);
+ });
+
+ it("rejects when network is missing", () => {
+ const { network: _n, ...rest } = makeCache();
+ expect(validateRabeAddressCache(rest)).toBe(false);
+ });
+});
+
+describe("rabe_connector active-address cache — serialization round-trip", () => {
+ it("serializes to valid JSON and deserializes back to the original object", () => {
+ const original = makeCache();
+ const json = serializeRabeAddressCache(original);
+
+ expect(typeof json).toBe("string");
+ expect(() => JSON.parse(json)).not.toThrow();
+
+ const restored = deserializeRabeAddressCache(json);
+ expect(restored).toEqual(original);
+ });
+
+ it("deserialization returns null for invalid JSON", () => {
+ expect(deserializeRabeAddressCache("{bad json")).toBeNull();
+ });
+
+ it("deserialization returns null for JSON that fails validation", () => {
+ const badJson = JSON.stringify({ version: 99, address: "NOT_AN_ADDRESS" });
+ expect(deserializeRabeAddressCache(badJson)).toBeNull();
+ });
+
+ it("deserialization returns null for an empty string", () => {
+ expect(deserializeRabeAddressCache("")).toBeNull();
+ });
+
+ it("deserialization returns null for a JSON null literal", () => {
+ expect(deserializeRabeAddressCache("null")).toBeNull();
+ });
+
+ it("serialized output contains the address and network fields", () => {
+ const cache = makeCache({ network: "mainnet" });
+ const json = serializeRabeAddressCache(cache);
+ expect(json).toContain(VALID_ADDRESS);
+ expect(json).toContain("mainnet");
+ });
+});
+
+describe("rabe_connector active-address cache — localStorage integration", () => {
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ localStorage.clear();
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ warnSpy.mockRestore();
+ });
+
+ it("RABE_CACHE_KEY is a non-empty string constant", () => {
+ expect(typeof RABE_CACHE_KEY).toBe("string");
+ expect(RABE_CACHE_KEY.length).toBeGreaterThan(0);
+ });
+
+ it("saveRabeAddressCache writes a valid entry to localStorage", () => {
+ saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK);
+
+ const raw = localStorage.getItem(RABE_CACHE_KEY);
+ expect(raw).not.toBeNull();
+
+ const parsed = deserializeRabeAddressCache(raw!);
+ expect(parsed).not.toBeNull();
+ expect(parsed!.address).toBe(VALID_ADDRESS);
+ expect(parsed!.network).toBe(VALID_NETWORK);
+ expect(parsed!.version).toBe(1);
+ expect(parsed!.savedAt).toBeGreaterThan(0);
+ });
+
+ it("loadRabeAddressCache returns null when the key is absent", () => {
+ expect(loadRabeAddressCache()).toBeNull();
+ });
+
+ it("loadRabeAddressCache returns the saved cache after saveRabeAddressCache", () => {
+ saveRabeAddressCache(VALID_ADDRESS, "mainnet");
+
+ const loaded = loadRabeAddressCache();
+ expect(loaded).not.toBeNull();
+ expect(loaded!.address).toBe(VALID_ADDRESS);
+ expect(loaded!.network).toBe("mainnet");
+ });
+
+ it("loadRabeAddressCache returns null for corrupt localStorage data", () => {
+ localStorage.setItem(RABE_CACHE_KEY, "!!!not-json!!!");
+ expect(loadRabeAddressCache()).toBeNull();
+ });
+
+ it("loadRabeAddressCache returns null when stored data fails validation", () => {
+ localStorage.setItem(
+ RABE_CACHE_KEY,
+ JSON.stringify({ version: 1, address: "bad", savedAt: -1, network: "testnet" })
+ );
+ expect(loadRabeAddressCache()).toBeNull();
+ });
+
+ it("clearRabeAddressCache removes the entry from localStorage", () => {
+ saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK);
+ expect(localStorage.getItem(RABE_CACHE_KEY)).not.toBeNull();
+
+ clearRabeAddressCache();
+ expect(localStorage.getItem(RABE_CACHE_KEY)).toBeNull();
+ });
+
+ it("clearRabeAddressCache is a no-op when the key does not exist", () => {
+ expect(() => clearRabeAddressCache()).not.toThrow();
+ expect(localStorage.getItem(RABE_CACHE_KEY)).toBeNull();
+ });
+
+ it("second save overwrites the first entry", () => {
+ const SECOND_ADDRESS = "GCCS3QYX4XXUWSTYIDP2R6XQFSVWZVWOWNSWTA37ZAND5Z4Z5K7L5I6Y";
+ saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK);
+ saveRabeAddressCache(SECOND_ADDRESS, "mainnet");
+
+ const loaded = loadRabeAddressCache();
+ expect(loaded!.address).toBe(SECOND_ADDRESS);
+ expect(loaded!.network).toBe("mainnet");
+ });
+
+ it("save → clear → load returns null", () => {
+ saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK);
+ clearRabeAddressCache();
+ expect(loadRabeAddressCache()).toBeNull();
+ });
+
+ it("savedAt timestamp is close to Date.now()", () => {
+ const before = Date.now();
+ saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK);
+ const after = Date.now();
+
+ const loaded = loadRabeAddressCache();
+ expect(loaded!.savedAt).toBeGreaterThanOrEqual(before);
+ expect(loaded!.savedAt).toBeLessThanOrEqual(after);
+ });
+
+ it("saveRabeAddressCache logs a warning when localStorage.setItem throws", () => {
+ const setItemSpy = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new DOMException("QuotaExceededError");
+ });
+
+ // Should not throw itself
+ expect(() =>
+ saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK)
+ ).not.toThrow();
+
+ // And should have logged a warning
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ const logged = String(warnSpy.mock.calls[0][0]);
+ expect(logged).toContain("[rabe_connector]");
+ expect(logged).toContain("CACHE WRITE FAILED");
+
+ setItemSpy.mockRestore();
+ });
+});
diff --git a/__tests__/wallet_disconnect_handler.component.test.ts b/__tests__/wallet_disconnect_handler.component.test.ts
index 5fb87fc..8bbfed7 100644
--- a/__tests__/wallet_disconnect_handler.component.test.ts
+++ b/__tests__/wallet_disconnect_handler.component.test.ts
@@ -3,6 +3,8 @@ import {
detectWalletExtensionById,
checkWalletAvailabilityById,
disconnectWalletWithCheck,
+ isWalletDisconnectUserRejected,
+ WalletDisconnectUserRejectedError,
type WalletDisconnectResult,
} from "@/app/lib/wallet_disconnect_handler";
@@ -855,3 +857,325 @@ describe("wallet_disconnect_handler checkWalletAvailabilityById detailed scenari
expect(errorSpy).toHaveBeenCalled();
});
});
+
+// ---------------------------------------------------------------------------
+// User signature rejection handling (#235)
+// ---------------------------------------------------------------------------
+
+describe("wallet_disconnect_handler user signature rejection handling", () => {
+ let warnSpy: ReturnType;
+ let errorSpy: ReturnType;
+ type DisconnectToastHandler = (
+ message: string,
+ type: "warning" | "error" | "info" | "success",
+ ) => void;
+
+ let toastSpy: ReturnType>;
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ toastSpy = vi.fn();
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ errorSpy.mockRestore();
+ });
+
+ // -------------------------------------------------------------------------
+ // isWalletDisconnectUserRejected detection function
+ // -------------------------------------------------------------------------
+
+ describe("isWalletDisconnectUserRejected detection", () => {
+ it("returns true for WalletDisconnectUserRejectedError instance", () => {
+ const error = new WalletDisconnectUserRejectedError();
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'user rejected' message", () => {
+ const error = new Error("user rejected transaction");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'user declined' message", () => {
+ const error = new Error("user declined the request");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'request rejected' message", () => {
+ const error = new Error("request rejected by wallet");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'denied by the user' message", () => {
+ const error = new Error("denied by the user");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'rejected by user' message", () => {
+ const error = new Error("rejected by user");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'canceled by user' message", () => {
+ const error = new Error("canceled by user");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns true for error with 'cancelled by user' message", () => {
+ const error = new Error("cancelled by user");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+
+ it("returns false for non-Error objects", () => {
+ expect(isWalletDisconnectUserRejected("user rejected")).toBe(false);
+ expect(isWalletDisconnectUserRejected(null)).toBe(false);
+ expect(isWalletDisconnectUserRejected(undefined)).toBe(false);
+ expect(isWalletDisconnectUserRejected(123)).toBe(false);
+ });
+
+ it("returns false for errors without rejection keywords", () => {
+ const error = new Error("network timeout");
+ expect(isWalletDisconnectUserRejected(error)).toBe(false);
+ });
+
+ it("is case-insensitive for rejection keywords", () => {
+ const error = new Error("USER REJECTED TRANSACTION");
+ expect(isWalletDisconnectUserRejected(error)).toBe(true);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // disconnectWalletWithCheck with user rejection
+ // -------------------------------------------------------------------------
+
+ describe("disconnectWalletWithCheck with user rejection", () => {
+ it("handles user rejection with toast notification", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("user rejected transaction");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "freighter",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.error).toBeNull();
+ expect(result.fallbackInstructions).toBeNull();
+ expect(result.installUrl).toBeNull();
+ expect(toastSpy).toHaveBeenCalledTimes(1);
+ expect(toastSpy).toHaveBeenCalledWith(
+ "Disconnect cancelled — you rejected the request in your wallet.",
+ "warning",
+ );
+ expect(warnSpy).toHaveBeenCalled();
+ const logged = String(warnSpy.mock.calls[0][0]);
+ expect(logged).toContain("[wallet_disconnect_handler]");
+ expect(logged).toContain("DISCONNECT REJECTED");
+ expect(logged).toContain("freighter");
+ });
+
+ it("handles user rejection without toast handler", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("user declined");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "albedo",
+ disconnectFn,
+ () => true,
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.error).toBeNull();
+ expect(toastSpy).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ it("handles WalletDisconnectUserRejectedError instance", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new WalletDisconnectUserRejectedError();
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "xbull",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.error).toBeNull();
+ expect(toastSpy).toHaveBeenCalledWith(
+ "Disconnect cancelled — you rejected the request in your wallet.",
+ "warning",
+ );
+ });
+
+ it("removes active key on user rejection", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("user rejected transaction");
+ });
+
+ // Register an active key first
+ const { registerActiveWalletKey } = await import("@/app/lib/wallet_disconnect_handler");
+ registerActiveWalletKey("freighter", "GTEST123");
+
+ const result = await disconnectWalletWithCheck(
+ "freighter",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ // Verify the key was removed
+ const { walletActiveKeysStore } = await import("@/app/lib/wallet_disconnect_handler");
+ expect(walletActiveKeysStore.hasActiveKey("freighter")).toBe(false);
+ });
+
+ it("handles 'request rejected' error message", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("request rejected by wallet");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "hana",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ expect(toastSpy).toHaveBeenCalled();
+ });
+
+ it("handles 'denied by the user' error message", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("denied by the user");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "freighter",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ expect(toastSpy).toHaveBeenCalled();
+ });
+
+ it("handles 'cancelled by user' error message", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("cancelled by user");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "albedo",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ expect(toastSpy).toHaveBeenCalled();
+ });
+
+ it("does not treat non-rejection errors as user rejection", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("network timeout");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "freighter",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("network timeout");
+ expect(toastSpy).not.toHaveBeenCalled();
+ expect(errorSpy).toHaveBeenCalled();
+ });
+
+ it("does not treat generic errors as user rejection", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("wallet disconnected unexpectedly");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "xbull",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("wallet disconnected unexpectedly");
+ expect(toastSpy).not.toHaveBeenCalled();
+ });
+
+ it("logs warning when user rejects disconnect", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("user rejected transaction");
+ });
+
+ await disconnectWalletWithCheck(
+ "freighter",
+ disconnectFn,
+ () => true,
+ undefined,
+ undefined,
+ toastSpy,
+ );
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ const logged = String(warnSpy.mock.calls[0][0]);
+ expect(logged).toContain("[wallet_disconnect_handler]");
+ expect(logged).toContain("DISCONNECT REJECTED");
+ expect(logged).toContain("freighter");
+ });
+
+ it("handles user rejection with pending transaction context", async () => {
+ const disconnectFn = vi.fn(async () => {
+ throw new Error("user rejected transaction");
+ });
+
+ const result = await disconnectWalletWithCheck(
+ "freighter",
+ disconnectFn,
+ () => true,
+ undefined,
+ { txId: "tx_123", status: "signing", context: "milestone release" },
+ toastSpy,
+ );
+
+ expect(result.success).toBe(true);
+ expect(toastSpy).toHaveBeenCalled();
+ // Should also log the pending transaction warning
+ expect(warnSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/DashboardList.stories.tsx b/app/components/DashboardList.stories.tsx
new file mode 100644
index 0000000..6e7c97b
--- /dev/null
+++ b/app/components/DashboardList.stories.tsx
@@ -0,0 +1,498 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+import LoadingSkeleton from "./LoadingSkeleton";
+import EmptyStateCard from "./EmptyStateCard";
+import MilestoneCard from "./MilestoneCard";
+
+// Mock job data for stories
+const mockJob = (overrides = {}) => ({
+ id: "job-1234567890abcdef",
+ client: "GCLIENTADDRESS1234567890123456789012345678901234",
+ freelancer: "GFREELANCERADDRESS12345678901234567890123456",
+ arbiter: "GARBITERADDRESS12345678901234567890123456789012",
+ funded: true,
+ milestones: [
+ { index: 0, amount: "250000000", status: "Pending" },
+ { index: 1, amount: "500000000", status: "Delivered" },
+ ],
+ tokenSymbol: "USDC",
+ tokenDecimals: 7,
+ ...overrides,
+});
+
+const mockJobs = [
+ mockJob({ id: "job-1", funded: true }),
+ mockJob({ id: "job-2", funded: false }),
+ mockJob({ id: "job-3", funded: true, milestones: [] }),
+];
+
+const mockActions = {
+ partialReleaseState: { phase: "idle" as const, error: null, txHash: null },
+ claimAutoReleaseState: { phase: "idle" as const, error: null, txHash: null },
+ isPartialReleasePending: false,
+ isClaimAutoReleasePending: false,
+ onMarkDelivered: fn(),
+ onApprove: fn(),
+ onDispute: fn(),
+ onPartialRelease: fn(),
+ onClaimAutoRelease: fn(),
+ onResolveDispute: fn(),
+};
+
+// Mock components for story isolation
+const MockDashboardList = ({ jobs = mockJobs, loading = false, error = null, expandedJobId = null, onExpand = fn() }) => {
+ const mockAddress = "GCLIENTADDRESS1234567890123456789012345678901234";
+
+ if (loading) return ;
+
+ if (error) {
+ return (
+
+
Error loading jobs
+
{error}
+
+ );
+ }
+
+ if (!jobs.length) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {jobs.map((job, index) => {
+ const isExpanded = expandedJobId === job.id;
+ const roleBadges = [
+ mockAddress === job.client ? "Client" : null,
+ mockAddress === job.freelancer ? "Freelancer" : null,
+ mockAddress === job.arbiter ? "Arbiter" : null,
+ ].filter(Boolean) as string[];
+
+ return (
+
+
onExpand(job.id)}
+ className="w-full text-left px-3 sm:px-5 py-3 sm:py-4 hover:bg-gray-800/50 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
+ aria-expanded={isExpanded}
+ aria-label={`Job #${job.id.slice(0, 8)}, ${job.funded ? "Funded" : "Not funded"}, ${isExpanded ? "collapse details" : "expand details"}`}
+ aria-controls={`job-details-${job.id}`}
+ >
+
+
+
Job #{job.id.slice(0, 8)}
+
{job.funded ? "Funded" : "Not funded"}
+
+
+ {roleBadges.map((badge) => (
+
+ {badge}
+
+ ))}
+
+ {isExpanded ? "Collapse" : "Expand"}
+
+
+
+
+
+ {isExpanded && (
+
+ {job.milestones?.length ? (
+ job.milestones.map((m) => (
+
+ ))
+ ) : (
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+const meta = {
+ title: "Components/DashboardList",
+ component: MockDashboardList,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "padded",
+ backgrounds: {
+ default: "dark",
+ values: [
+ { name: "dark", value: "#030712" },
+ { name: "light", value: "#ffffff" },
+ ],
+ },
+ },
+ argTypes: {
+ loading: { control: "boolean", description: "Show loading skeleton" },
+ error: { control: "text", description: "Error message to display" },
+ expandedJobId: { control: "text", description: "ID of expanded job" },
+ onExpand: { action: "expanded" },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// ---------------------------------------------------------------------------
+// 1. Loading state - skeleton shown
+// ---------------------------------------------------------------------------
+export const Loading: Story = {
+ name: "Loading — skeleton",
+ args: {
+ loading: true,
+ jobs: [],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 2. Error state
+// ---------------------------------------------------------------------------
+export const Error: Story = {
+ name: "Error — backend failure",
+ args: {
+ loading: false,
+ jobs: [],
+ error: "Failed to fetch jobs. Please try again later.",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 3. Empty state - no jobs
+// ---------------------------------------------------------------------------
+export const Empty: Story = {
+ name: "Empty — no jobs found",
+ args: {
+ loading: false,
+ jobs: [],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 4. Single job - funded
+// ---------------------------------------------------------------------------
+export const SingleJobFunded: Story = {
+ name: "Single Job — funded",
+ args: {
+ loading: false,
+ jobs: [mockJob({ id: "job-funded-1", funded: true })],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 5. Single job - not funded
+// ---------------------------------------------------------------------------
+export const SingleJobNotFunded: Story = {
+ name: "Single Job — not funded",
+ args: {
+ loading: false,
+ jobs: [mockJob({ id: "job-unfunded-1", funded: false })],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 6. Multiple jobs - mixed states
+// ---------------------------------------------------------------------------
+export const MultipleJobs: Story = {
+ name: "Multiple Jobs — mixed funded/unfunded",
+ args: {
+ loading: false,
+ jobs: [
+ mockJob({ id: "job-1-funded", funded: true }),
+ mockJob({ id: "job-2-unfunded", funded: false }),
+ mockJob({ id: "job-3-funded", funded: true }),
+ ],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 7. Job with milestones - expanded
+// ---------------------------------------------------------------------------
+export const ExpandedWithMilestones: Story = {
+ name: "Expanded — job with milestones",
+ args: {
+ loading: false,
+ jobs: [
+ mockJob({ id: "job-with-milestones", funded: true, milestones: [
+ { index: 0, amount: "250000000", status: "Pending" },
+ { index: 1, amount: "500000000", status: "Delivered" },
+ { index: 2, amount: "750000000", status: "Released" },
+ ]}),
+ ],
+ error: null,
+ expandedJobId: "job-with-milestones",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 8. Job with no milestones - expanded
+// ---------------------------------------------------------------------------
+export const ExpandedNoMilestones: Story = {
+ name: "Expanded — job with no milestones",
+ args: {
+ loading: false,
+ jobs: [mockJob({ id: "job-no-milestones", funded: true, milestones: [] })],
+ error: null,
+ expandedJobId: "job-no-milestones",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 9. Role badges - client view
+// ---------------------------------------------------------------------------
+export const ClientView: Story = {
+ name: "Role — client view",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-client-view",
+ funded: true,
+ client: "GCURRENTUSER1234567890123456789012345678901234",
+ freelancer: "GFREELANCERADDRESS12345678901234567890123456",
+ })],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 10. Role badges - freelancer view
+// ---------------------------------------------------------------------------
+export const FreelancerView: Story = {
+ name: "Role — freelancer view",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-freelancer-view",
+ funded: true,
+ client: "GCLIENTADDRESS1234567890123456789012345678901234",
+ freelancer: "GCURRENTUSER1234567890123456789012345678901234",
+ })],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 11. Role badges - arbiter view
+// ---------------------------------------------------------------------------
+export const ArbiterView: Story = {
+ name: "Role — arbiter view",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-arbiter-view",
+ funded: true,
+ client: "GCLIENTADDRESS1234567890123456789012345678901234",
+ freelancer: "GFREELANCERADDRESS12345678901234567890123456",
+ arbiter: "GCURRENTUSER1234567890123456789012345678901234",
+ })],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 12. Role badges - multiple roles (client + freelancer)
+// ---------------------------------------------------------------------------
+export const MultiRoleView: Story = {
+ name: "Role — multiple roles (client + freelancer)",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-multi-role",
+ funded: true,
+ client: "GCURRENTUSER1234567890123456789012345678901234",
+ freelancer: "GCURRENTUSER1234567890123456789012345678901234",
+ arbiter: "GARBITERADDRESS12345678901234567890123456789012",
+ })],
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 13. Multiple jobs expanded - first expanded
+// ---------------------------------------------------------------------------
+export const FirstJobExpanded: Story = {
+ name: "Multiple — first job expanded",
+ args: {
+ loading: false,
+ jobs: [
+ mockJob({ id: "job-1-expanded", funded: true }),
+ mockJob({ id: "job-2-collapsed", funded: false }),
+ mockJob({ id: "job-3-collapsed", funded: true }),
+ ],
+ error: null,
+ expandedJobId: "job-1-expanded",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 14. Pagination - many jobs
+// ---------------------------------------------------------------------------
+export const ManyJobs: Story = {
+ name: "Many Jobs — pagination scenario",
+ args: {
+ loading: false,
+ jobs: Array.from({ length: 12 }, (_, i) =>
+ mockJob({ id: `job-${i + 1}`, funded: i % 2 === 0 })
+ ),
+ error: null,
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 15. Large milestone count - layout stress
+// ---------------------------------------------------------------------------
+export const LargeMilestoneCount: Story = {
+ name: "Stress — many milestones",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-many-milestones",
+ funded: true,
+ milestones: Array.from({ length: 10 }, (_, i) => ({
+ index: i,
+ amount: String(100000000 * (i + 1)),
+ status: i % 3 === 0 ? "Pending" : i % 3 === 1 ? "Delivered" : "Released",
+ })),
+ })],
+ error: null,
+ expandedJobId: "job-many-milestones",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 16. All states overview
+// ---------------------------------------------------------------------------
+export const AllStates: Story = {
+ name: "All States — overview",
+ render: () => (
+
+
+
Loading
+
+
+
+
Error
+
+
+
+
Empty
+
+
+
+
Single Funded
+
+
+
+
Multiple Mixed
+
+
+
+ ),
+};
+
+// ---------------------------------------------------------------------------
+// 17. Interactive - expanded job
+// ---------------------------------------------------------------------------
+export const InteractiveExpanded: Story = {
+ name: "Interactive — expand/collapse",
+ args: {
+ loading: false,
+ jobs: [
+ mockJob({ id: "interactive-1", funded: true }),
+ mockJob({ id: "interactive-2", funded: false }),
+ ],
+ expandedJobId: "interactive-1",
+ },
+ parameters: {
+ pseudo: { hover: true },
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 18. Job with auto-release countdown (delivered milestone)
+// ---------------------------------------------------------------------------
+export const WithAutoReleaseCountdown: Story = {
+ name: "Delivered — auto-release countdown",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-auto-release",
+ funded: true,
+ milestones: [
+ { index: 0, amount: "250000000", status: "Pending" },
+ { index: 1, amount: "500000000", status: "Delivered" },
+ ],
+ })],
+ error: null,
+ expandedJobId: "job-auto-release",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 19. Disputed milestone
+// ---------------------------------------------------------------------------
+export const DisputedMilestone: Story = {
+ name: "Disputed — milestone in dispute",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-disputed",
+ funded: true,
+ milestones: [
+ { index: 0, amount: "250000000", status: "Pending" },
+ { index: 1, amount: "500000000", status: "Disputed" },
+ ],
+ })],
+ error: null,
+ expandedJobId: "job-disputed",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// 20. Partially released milestone
+// ---------------------------------------------------------------------------
+export const PartiallyReleased: Story = {
+ name: "Partially Released — 60% released",
+ args: {
+ loading: false,
+ jobs: [mockJob({
+ id: "job-partial",
+ funded: true,
+ milestones: [
+ { index: 0, amount: "1000000000", status: "PartiallyReleased", releasedAmount: "600000000" },
+ ],
+ })],
+ error: null,
+ expandedJobId: "job-partial",
+ },
+};
\ No newline at end of file
diff --git a/app/components/LoadingSkeleton.tsx b/app/components/LoadingSkeleton.tsx
index 26ed027..fd37b0d 100644
--- a/app/components/LoadingSkeleton.tsx
+++ b/app/components/LoadingSkeleton.tsx
@@ -1,3 +1,10 @@
+interface ValidationError {
+ /** Human-readable error message. */
+ message: string;
+ /** Optional field/input this error belongs to. */
+ field?: string;
+}
+
interface LoadingSkeletonProps {
className?: string;
/**
@@ -9,6 +16,28 @@ interface LoadingSkeletonProps {
onClick?: () => void;
"aria-label"?: string;
tabIndex?: number;
+ /**
+ * A single validation error to surface as an accessible alert
+ * (role="alert", aria-live="assertive").
+ */
+ error?: string | null;
+ /**
+ * Multiple validation errors keyed by field, each rendered as an
+ * accessible alert when present.
+ */
+ validationErrors?: ValidationError[] | Record | null;
+}
+
+/**
+ * Returns true for plain objects (including records and single-element
+ * arrays), used to normalize the flexible `validationErrors` prop.
+ */
+function isRecord(value: unknown): value is Record {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value)
+ );
}
/**
@@ -25,6 +54,8 @@ export default function LoadingSkeleton({
onClick,
"aria-label": ariaLabel,
tabIndex,
+ error = null,
+ validationErrors = null,
}: LoadingSkeletonProps = {}) {
const interactiveClasses = interactive
? "cursor-pointer transition-all duration-200 ease-in-out hover:border-gray-700 hover:bg-gray-900/90 hover:shadow-lg hover:shadow-gray-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950"
@@ -34,12 +65,27 @@ export default function LoadingSkeleton({
? "opacity-50 cursor-not-allowed pointer-events-none"
: "";
+ // Normalize the validation errors into a stable list of { message, field }.
+ const renderedErrors: ValidationError[] = (() => {
+ if (Array.isArray(validationErrors) || isRecord(validationErrors)) {
+ return Object.entries(validationErrors).map(([key, value]) => {
+ if (typeof value === "string") {
+ return { field: key, message: value };
+ }
+ return { field: value?.field ?? key, message: value?.message ?? "" };
+ });
+ }
+ return error ? [{ message: error }] : [];
+ })();
+
return (
0 ? "loading-skeleton-errors" : undefined}
aria-disabled={disabled ? "true" : undefined}
tabIndex={interactive ? (disabled ? -1 : tabIndex ?? 0) : undefined}
onClick={!disabled ? onClick : undefined}
@@ -56,9 +102,31 @@ export default function LoadingSkeleton({
data-testid="loading-skeleton"
>
Loading job data…
+ {renderedErrors.length > 0 && (
+
+ {renderedErrors.map((validationError, index) => (
+
+ {validationError.field && (
+ {validationError.field}:
+ )}
+ {validationError.message}
+
+ ))}
+
+ )}
{/* Caps the skeleton on small viewports so it scrolls internally
- instead of pushing the surrounding controls off-screen, and
- keeps overscroll inside the wrapper rather than the page. */}
+ instead of pushing the surrounding controls off-screen, and
+ keeps overscroll inside the wrapper rather than the page. */}
void>();
+
+function subscribe(cb: () => void) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+}
+
+function notifyListeners() {
+ listeners.forEach((cb) => cb());
+}
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+interface ThemeContextValue {
+ theme: Theme;
+ toggleTheme: () => void;
+}
+
+const ThemeContext = createContext(null);
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ // useSyncExternalStore avoids the react-hooks/set-state-in-effect lint rule.
+ // getServerSnapshot returns "dark" so SSR output is deterministic.
+ const theme = useSyncExternalStore(subscribe, readTheme, () => "dark" as Theme);
+
+ const toggleTheme = useCallback(() => {
+ const next: Theme = readTheme() === "dark" ? "light" : "dark";
+ applyTheme(next);
+ notifyListeners();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
+ return ctx;
+}
diff --git a/app/lib/rabe_connector.ts b/app/lib/rabe_connector.ts
index 4010f13..3615fdd 100644
--- a/app/lib/rabe_connector.ts
+++ b/app/lib/rabe_connector.ts
@@ -401,3 +401,160 @@ export async function runRabeSign(
throw err;
}
}
+
+
+// ---------------------------------------------------------------------------
+// Secure persistent caching for active Rabe wallet addresses
+// ---------------------------------------------------------------------------
+
+/** localStorage key under which the serialized address cache is stored. */
+export const RABE_CACHE_KEY = "rabe_active_address_cache";
+
+/**
+ * Shape of the cache entry persisted to localStorage.
+ * Using a versioned envelope makes future migrations explicit.
+ */
+export interface RabeActiveAddressCache {
+ /** Schema version — increment when the shape changes. */
+ version: 1;
+ /** The Stellar public key of the connected wallet (G… address). */
+ address: string;
+ /** Unix-ms timestamp of when the entry was last written. */
+ savedAt: number;
+ /** Network the wallet was connected to when the address was cached. */
+ network: RabeNetwork;
+}
+
+// ---- Validation helpers ---------------------------------------------------
+
+/**
+ * Returns true when `value` looks like a valid Stellar public key.
+ * Public keys are 56 characters, start with "G", and are Base32 (A-Z 2-7).
+ */
+function isValidStellarAddress(value: string): boolean {
+ return /^G[A-Z2-7]{55}$/.test(value);
+}
+
+/**
+ * Returns true when `value` is one of the known {@link RabeNetwork} literals.
+ */
+function isValidNetwork(value: unknown): value is RabeNetwork {
+ return value === "mainnet" || value === "testnet";
+}
+
+// ---- Public cache API -----------------------------------------------------
+
+/**
+ * Validates that `raw` conforms to the {@link RabeActiveAddressCache} shape.
+ * Returns `true` when every required field passes its type and value check.
+ */
+export function validateRabeAddressCache(
+ raw: unknown
+): raw is RabeActiveAddressCache {
+ if (!raw || typeof raw !== "object") return false;
+
+ const candidate = raw as Record;
+
+ if (candidate.version !== 1) return false;
+ if (typeof candidate.address !== "string") return false;
+ if (!isValidStellarAddress(candidate.address)) return false;
+ if (typeof candidate.savedAt !== "number") return false;
+ if (!Number.isFinite(candidate.savedAt) || candidate.savedAt <= 0)
+ return false;
+ if (!isValidNetwork(candidate.network)) return false;
+
+ return true;
+}
+
+/**
+ * Serializes an {@link RabeActiveAddressCache} entry to a JSON string.
+ * Exposed for testing; normal callers should use {@link saveRabeAddressCache}.
+ */
+export function serializeRabeAddressCache(
+ cache: RabeActiveAddressCache
+): string {
+ return JSON.stringify(cache);
+}
+
+/**
+ * Parses a JSON string and validates it as an {@link RabeActiveAddressCache}.
+ * Returns `null` when parsing fails or the data does not pass validation.
+ */
+export function deserializeRabeAddressCache(
+ raw: string
+): RabeActiveAddressCache | null {
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return validateRabeAddressCache(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Writes the active-address cache to localStorage.
+ *
+ * Safe to call in SSR contexts — the write is skipped when `window` is
+ * unavailable so Next.js server renders do not throw.
+ */
+export function saveRabeAddressCache(
+ address: string,
+ network: RabeNetwork
+): void {
+ if (typeof window === "undefined") return;
+
+ const cache: RabeActiveAddressCache = {
+ version: 1,
+ address,
+ savedAt: Date.now(),
+ network,
+ };
+
+ try {
+ localStorage.setItem(RABE_CACHE_KEY, serializeRabeAddressCache(cache));
+ } catch (err) {
+ // localStorage may be unavailable (private mode quota exceeded, etc.)
+ logRabeWarning(
+ "CACHE WRITE FAILED",
+ "Could not persist active address cache to localStorage.",
+ { err }
+ );
+ }
+}
+
+/**
+ * Reads and validates the active-address cache from localStorage.
+ *
+ * Returns `null` when:
+ * - the code is running server-side,
+ * - the key is absent,
+ * - the stored value is corrupt or fails validation.
+ */
+export function loadRabeAddressCache(): RabeActiveAddressCache | null {
+ if (typeof window === "undefined") return null;
+
+ try {
+ const raw = localStorage.getItem(RABE_CACHE_KEY);
+ if (raw === null) return null;
+ return deserializeRabeAddressCache(raw);
+ } catch {
+ // Gracefully handle any unexpected storage errors.
+ return null;
+ }
+}
+
+/**
+ * Removes the active-address cache entry from localStorage.
+ *
+ * Call this on wallet disconnect or when the cached address can no longer
+ * be verified as active.
+ */
+export function clearRabeAddressCache(): void {
+ if (typeof window === "undefined") return;
+
+ try {
+ localStorage.removeItem(RABE_CACHE_KEY);
+ } catch {
+ // Ignore — if we can't remove it, there is nothing more to do.
+ }
+}
diff --git a/app/lib/wallet_disconnect_handler.ts b/app/lib/wallet_disconnect_handler.ts
index dcf5d5d..55766e2 100644
--- a/app/lib/wallet_disconnect_handler.ts
+++ b/app/lib/wallet_disconnect_handler.ts
@@ -469,10 +469,15 @@ export interface PendingTxSnapshot {
* via `pendingTx` so it is logged as a console.warn for post-mortem
* debugging — the handler does not change control flow based on it.
*
+ * User signature rejections are caught gracefully: a warning toast is shown
+ * and the function returns success (since the user intentionally cancelled).
+ *
* @param walletId - The wallet provider ID.
* @param disconnectFn - The actual disconnect function (e.g. StellarWalletsKit.disconnect()).
* @param detector - Optional availability-detector override for tests.
+ * @param options - Optional timeout and cleanup options.
* @param pendingTx - Optional snapshot of an in-flight transaction at disconnect time.
+ * @param showToast - Optional toast handler for user rejection warnings.
*/
export async function disconnectWalletWithCheck(
walletId: string,
@@ -480,6 +485,7 @@ export async function disconnectWalletWithCheck(
detector?: () => boolean,
options?: WalletDisconnectTimeoutOptions,
pendingTx?: PendingTxSnapshot,
+ showToast?: (message: string, type: "warning" | "error" | "info" | "success") => void,
): Promise {
// Warn immediately if a transaction was in flight when disconnect was called.
if (pendingTx && (pendingTx.txId ?? pendingTx.status ?? pendingTx.context)) {
@@ -527,6 +533,28 @@ export async function disconnectWalletWithCheck(
installUrl: null,
};
} catch (err) {
+ // Handle user rejection gracefully - show warning toast and return success
+ if (isWalletDisconnectUserRejected(err)) {
+ console.warn(
+ `${LOG_PREFIX} DISCONNECT REJECTED by user for "${walletId}":`,
+ err,
+ );
+ // Remove from active keys store even on user rejection
+ walletActiveKeysStore.removeActiveKey(walletId);
+ if (showToast) {
+ showToast(
+ "Disconnect cancelled — you rejected the request in your wallet.",
+ "warning",
+ );
+ }
+ return {
+ success: true,
+ error: null,
+ fallbackInstructions: null,
+ installUrl: null,
+ };
+ }
+
const message =
err instanceof Error
? err.message
@@ -748,6 +776,37 @@ export function warnOnDisconnectNetworkMismatch(
return state;
}
+// =============================================================
+// User signature rejection handling (#235)
+// =============================================================
+
+export class WalletDisconnectUserRejectedError extends Error {
+ constructor(message = "user rejected transaction") {
+ super(message);
+ this.name = "WalletDisconnectUserRejectedError";
+ }
+}
+
+/**
+ * Detects "user rejected the signature request" style errors from wallet
+ * disconnect operations. Mirrors the pattern used in albedo_connector and
+ * freighter_connector for consistency across the codebase.
+ */
+export function isWalletDisconnectUserRejected(err: unknown): boolean {
+ if (err instanceof WalletDisconnectUserRejectedError) return true;
+ if (!(err instanceof Error)) return false;
+ const message = err.message.toLowerCase();
+ return (
+ message.includes("user rejected") ||
+ message.includes("user declined") ||
+ message.includes("request rejected") ||
+ message.includes("denied by the user") ||
+ message.includes("rejected by user") ||
+ message.includes("canceled by user") ||
+ message.includes("cancelled by user")
+ );
+}
+
// =============================================================
// Gas estimation / simulation fee warnings (#240)
// =============================================================
diff --git a/vitest.setup.ts b/vitest.setup.ts
index 179b84a..46e17df 100644
--- a/vitest.setup.ts
+++ b/vitest.setup.ts
@@ -1,5 +1,25 @@
import "@testing-library/jest-dom/vitest";
+// jsdom doesn't implement matchMedia. Provide a minimal stub so any code
+// that calls window.matchMedia("...").matches doesn't throw.
+if (typeof window !== "undefined" && typeof window.matchMedia === "undefined") {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ configurable: true,
+ value: (query: string): MediaQueryList =>
+ ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList,
+ });
+}
+
// jsdom environment: normalise the web-storage globals across Node versions.
//
// Newer Node releases ship an experimental `localStorage` global that reads as