From 1b07f280ffe9afe6f44696edf8a1cd2e728d62c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20Yakupo=C4=9Flu?= Date: Tue, 29 Sep 2026 15:51:51 +0300 Subject: [PATCH 1/2] feat(soroban-footprint-diff): compare simulation footprint access modes offline --- features/soroban-footprint-diff/README.md | 22 +++ .../SorobanFootprintDiffPanel.test.tsx | 40 +++++ .../__tests__/a11y.test.tsx | 27 ++++ .../__tests__/access-diff.test.ts | 27 ++++ .../__tests__/footprint-parser.test.ts | 38 +++++ .../__tests__/format.test.ts | 25 +++ .../__tests__/resource-summary.test.ts | 32 ++++ .../__tests__/schema.test.ts | 36 +++++ .../__tests__/sorobanFootprintDiff.test.ts | 36 +++++ .../useSorobanFootprintDiff.test.tsx | 46 ++++++ .../components/AccessChanges.tsx | 24 +++ .../components/FootprintGroups.tsx | 46 ++++++ .../components/ResourceSummary.tsx | 52 +++++++ .../SorobanFootprintDiffEmptyState.tsx | 15 ++ .../components/SorobanFootprintDiffForm.tsx | 60 ++++++++ .../components/SorobanFootprintDiffPanel.tsx | 45 ++++++ .../components/SorobanFootprintDiffResult.tsx | 49 ++++++ features/soroban-footprint-diff/copy.ts | 57 +++++++ .../e2e/access-mode-change.spec.ts | 15 ++ .../e2e/soroban-footprint-diff.spec.ts | 17 +++ .../fixtures/access-conflicts.fixture.ts | 4 + .../fixtures/simulations.fixture.ts | 5 + .../fixtures/sorobanFootprintDiff.fixture.ts | 45 ++++++ .../hooks/useSorobanFootprintDiff.ts | 56 +++++++ .../soroban-footprint-diff/lib/access-diff.ts | 84 ++++++++++ .../lib/footprint-parser.ts | 143 ++++++++++++++++++ features/soroban-footprint-diff/lib/format.ts | 21 +++ .../lib/resource-summary.ts | 18 +++ .../lib/sorobanFootprintDiff.errors.ts | 7 + .../lib/sorobanFootprintDiff.ts | 30 ++++ features/soroban-footprint-diff/manifest.ts | 16 ++ .../soroban-footprint-diff/msw/handlers.ts | 1 + features/soroban-footprint-diff/panel.tsx | 1 + features/soroban-footprint-diff/schema.ts | 26 ++++ features/soroban-footprint-diff/types.ts | 51 +++++++ 35 files changed, 1217 insertions(+) create mode 100644 features/soroban-footprint-diff/README.md create mode 100644 features/soroban-footprint-diff/__tests__/SorobanFootprintDiffPanel.test.tsx create mode 100644 features/soroban-footprint-diff/__tests__/a11y.test.tsx create mode 100644 features/soroban-footprint-diff/__tests__/access-diff.test.ts create mode 100644 features/soroban-footprint-diff/__tests__/footprint-parser.test.ts create mode 100644 features/soroban-footprint-diff/__tests__/format.test.ts create mode 100644 features/soroban-footprint-diff/__tests__/resource-summary.test.ts create mode 100644 features/soroban-footprint-diff/__tests__/schema.test.ts create mode 100644 features/soroban-footprint-diff/__tests__/sorobanFootprintDiff.test.ts create mode 100644 features/soroban-footprint-diff/__tests__/useSorobanFootprintDiff.test.tsx create mode 100644 features/soroban-footprint-diff/components/AccessChanges.tsx create mode 100644 features/soroban-footprint-diff/components/FootprintGroups.tsx create mode 100644 features/soroban-footprint-diff/components/ResourceSummary.tsx create mode 100644 features/soroban-footprint-diff/components/SorobanFootprintDiffEmptyState.tsx create mode 100644 features/soroban-footprint-diff/components/SorobanFootprintDiffForm.tsx create mode 100644 features/soroban-footprint-diff/components/SorobanFootprintDiffPanel.tsx create mode 100644 features/soroban-footprint-diff/components/SorobanFootprintDiffResult.tsx create mode 100644 features/soroban-footprint-diff/copy.ts create mode 100644 features/soroban-footprint-diff/e2e/access-mode-change.spec.ts create mode 100644 features/soroban-footprint-diff/e2e/soroban-footprint-diff.spec.ts create mode 100644 features/soroban-footprint-diff/fixtures/access-conflicts.fixture.ts create mode 100644 features/soroban-footprint-diff/fixtures/simulations.fixture.ts create mode 100644 features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture.ts create mode 100644 features/soroban-footprint-diff/hooks/useSorobanFootprintDiff.ts create mode 100644 features/soroban-footprint-diff/lib/access-diff.ts create mode 100644 features/soroban-footprint-diff/lib/footprint-parser.ts create mode 100644 features/soroban-footprint-diff/lib/format.ts create mode 100644 features/soroban-footprint-diff/lib/resource-summary.ts create mode 100644 features/soroban-footprint-diff/lib/sorobanFootprintDiff.errors.ts create mode 100644 features/soroban-footprint-diff/lib/sorobanFootprintDiff.ts create mode 100644 features/soroban-footprint-diff/manifest.ts create mode 100644 features/soroban-footprint-diff/msw/handlers.ts create mode 100644 features/soroban-footprint-diff/panel.tsx create mode 100644 features/soroban-footprint-diff/schema.ts create mode 100644 features/soroban-footprint-diff/types.ts diff --git a/features/soroban-footprint-diff/README.md b/features/soroban-footprint-diff/README.md new file mode 100644 index 0000000..014890d --- /dev/null +++ b/features/soroban-footprint-diff/README.md @@ -0,0 +1,22 @@ +# Soroban Footprint Difference Inspector + +Fully offline comparison of two pasted simulation-result JSON payloads. + +## Behavior + +1. Validate both pastes (non-empty, size-capped). +2. Require `footprint.readOnly` / `footprint.readWrite` arrays. +3. Decode ledger keys (opaque labels or LedgerKey XDR). +4. Treat the same key in both sets as an access-mode conflict. +5. Diff additions, removals and mode changes; show optional resource fields as simulation outputs only. + +## Data boundaries + +- No network calls (`networks: []`, `offline: true`). +- Pastes are never persisted. +- Input size capped at 100,000 characters per side. + +## Design decisions + +- A footprint is a proposal for one simulated transaction, not proof of a later ledger write. +- Resource fields are shown only when present and labeled as simulation output. diff --git a/features/soroban-footprint-diff/__tests__/SorobanFootprintDiffPanel.test.tsx b/features/soroban-footprint-diff/__tests__/SorobanFootprintDiffPanel.test.tsx new file mode 100644 index 0000000..1392826 --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/SorobanFootprintDiffPanel.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { SorobanFootprintDiffPanel } from "@/features/soroban-footprint-diff/components/SorobanFootprintDiffPanel"; +import { copy, errorCopy } from "@/features/soroban-footprint-diff/copy"; +import { + firstSimulationJson, + secondSimulationJson +} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture"; + +type User = ReturnType["user"]; + +async function compare(user: User, first: string, second: string) { + await user.click(screen.getByLabelText(copy.firstLabel)); + await user.paste(first); + await user.click(screen.getByLabelText(copy.secondLabel)); + await user.paste(second); + await user.click(screen.getByRole("button", { name: copy.submit })); +} + +describe("SorobanFootprintDiffPanel", () => { + it("shows empty state", () => { + renderFeature(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("renders a successful comparison", async () => { + const { user } = renderFeature(); + await compare(user, firstSimulationJson, secondSimulationJson); + expect(await screen.findByText(copy.modeTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.addedTitle)).toBeInTheDocument(); + }); + + it("explains empty second result", async () => { + const { user } = renderFeature(); + await user.click(screen.getByLabelText(copy.firstLabel)); + await user.paste(firstSimulationJson); + await user.click(screen.getByRole("button", { name: copy.submit })); + expect(await screen.findByText(errorCopy.empty_second_result.title)).toBeInTheDocument(); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/a11y.test.tsx b/features/soroban-footprint-diff/__tests__/a11y.test.tsx new file mode 100644 index 0000000..1444e91 --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/a11y.test.tsx @@ -0,0 +1,27 @@ +import { describe, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { SorobanFootprintDiffPanel } from "@/features/soroban-footprint-diff/components/SorobanFootprintDiffPanel"; +import { copy } from "@/features/soroban-footprint-diff/copy"; +import { + firstSimulationJson, + secondSimulationJson +} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture"; + +describe("SorobanFootprintDiffPanel accessibility", () => { + it("has no WCAG A/AA violations idle", async () => { + const { container } = renderFeature(); + await expectNoAxeViolations(container); + }); + + it("has no WCAG A/AA violations after success", async () => { + const { container, user } = renderFeature(); + await user.click(screen.getByLabelText(copy.firstLabel)); + await user.paste(firstSimulationJson); + await user.click(screen.getByLabelText(copy.secondLabel)); + await user.paste(secondSimulationJson); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.modeTitle); + await expectNoAxeViolations(container); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/access-diff.test.ts b/features/soroban-footprint-diff/__tests__/access-diff.test.ts new file mode 100644 index 0000000..d99982a --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/access-diff.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { diffAccessModes } from "@/features/soroban-footprint-diff/lib/access-diff"; + +describe("diffAccessModes", () => { + it("reports additions, removals and mode changes", () => { + const diff = diffAccessModes( + [ + { id: "A", label: "A", mode: "read_only" }, + { id: "B", label: "B", mode: "read_only" }, + { id: "C", label: "C", mode: "read_write" } + ], + [ + { id: "A", label: "A", mode: "read_only" }, + { id: "B", label: "B", mode: "read_write" }, + { id: "D", label: "D", mode: "read_write" } + ] + ); + + expect(diff.added.map((key) => key.id)).toEqual(["D"]); + expect(diff.removed.map((key) => key.id)).toEqual(["C"]); + expect(diff.modeChanges.find((change) => change.id === "B")).toMatchObject({ + kind: "mode_changed", + before: "read_only", + after: "read_write" + }); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/footprint-parser.test.ts b/features/soroban-footprint-diff/__tests__/footprint-parser.test.ts new file mode 100644 index 0000000..ab5bafb --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/footprint-parser.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { parseSimulationFootprint } from "@/features/soroban-footprint-diff/lib/footprint-parser"; +import { + conflictSimulationJson, + firstSimulationJson, + invalidXdrSimulationJson +} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture"; + +describe("parseSimulationFootprint", () => { + it("parses labeled keys and resources", () => { + const result = parseSimulationFootprint(firstSimulationJson); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.keys).toHaveLength(3); + expect(result.value.resources.minResourceFee).toBe("100"); + }); + + it("marks the same key in both sets as conflict", () => { + const result = parseSimulationFootprint(conflictSimulationJson); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.keys).toEqual([ + { id: "CONTRACT_DATA:X", label: "CONTRACT_DATA:X", mode: "conflict" } + ]); + }); + + it("rejects invalid JSON shapes", () => { + expect(parseSimulationFootprint("{")).toEqual({ ok: false, code: "invalid_json" }); + expect(parseSimulationFootprint("{}")).toEqual({ ok: false, code: "invalid_json" }); + }); + + it("rejects undecodable XDR-looking entries", () => { + expect(parseSimulationFootprint(invalidXdrSimulationJson)).toEqual({ + ok: false, + code: "invalid_footprint_xdr" + }); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/format.test.ts b/features/soroban-footprint-diff/__tests__/format.test.ts new file mode 100644 index 0000000..7459899 --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/format.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { formatChangeLine, formatKeyLabel, formatSummary } from "@/features/soroban-footprint-diff/lib/format"; + +describe("format helpers", () => { + it("formats key labels and change lines", () => { + expect( + formatKeyLabel({ id: "A", label: "CONTRACT_DATA:A", mode: "read_only" }) + ).toContain("read-only"); + expect( + formatChangeLine({ + id: "B", + label: "CONTRACT_DATA:B", + before: "read_only", + after: "read_write", + kind: "mode_changed" + }) + ).toContain("read-only → read-write"); + }); + + it("formats summary counts", () => { + expect(formatSummary({ added: 1, removed: 2, modeChanges: 3 })).toBe( + "1 added · 2 removed · 3 mode changes" + ); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/resource-summary.test.ts b/features/soroban-footprint-diff/__tests__/resource-summary.test.ts new file mode 100644 index 0000000..b47c595 --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/resource-summary.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { summarizeResources } from "@/features/soroban-footprint-diff/lib/resource-summary"; + +describe("summarizeResources", () => { + it("includes only present fields", () => { + expect( + summarizeResources({ + minResourceFee: "100", + cpuInsns: null, + memBytes: "2", + labeledAsSimulation: true + }) + ).toEqual({ + hasAny: true, + rows: [ + { label: "minResourceFee", value: "100" }, + { label: "memBytes", value: "2" } + ] + }); + }); + + it("reports empty summaries", () => { + expect( + summarizeResources({ + minResourceFee: null, + cpuInsns: null, + memBytes: null, + labeledAsSimulation: true + }).hasAny + ).toBe(false); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/schema.test.ts b/features/soroban-footprint-diff/__tests__/schema.test.ts new file mode 100644 index 0000000..fe7f69f --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/schema.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { parseSorobanFootprintDiffInput } from "@/features/soroban-footprint-diff/schema"; +import { MAX_INPUT_CHARS } from "@/features/soroban-footprint-diff/types"; +import { + firstSimulationJson, + secondSimulationJson +} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture"; + +describe("parseSorobanFootprintDiffInput", () => { + it("accepts two non-empty payloads under the size cap", () => { + expect( + parseSorobanFootprintDiffInput({ + firstResult: firstSimulationJson, + secondResult: secondSimulationJson + }).ok + ).toBe(true); + }); + + it("rejects empty sides", () => { + expect( + parseSorobanFootprintDiffInput({ firstResult: "", secondResult: secondSimulationJson }) + ).toEqual({ ok: false, code: "empty_first_result" }); + expect( + parseSorobanFootprintDiffInput({ firstResult: firstSimulationJson, secondResult: " " }) + ).toEqual({ ok: false, code: "empty_second_result" }); + }); + + it("rejects oversized pastes", () => { + expect( + parseSorobanFootprintDiffInput({ + firstResult: "x".repeat(MAX_INPUT_CHARS + 1), + secondResult: secondSimulationJson + }) + ).toEqual({ ok: false, code: "too_large" }); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/sorobanFootprintDiff.test.ts b/features/soroban-footprint-diff/__tests__/sorobanFootprintDiff.test.ts new file mode 100644 index 0000000..fe74389 --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/sorobanFootprintDiff.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { runSorobanFootprintDiff } from "@/features/soroban-footprint-diff/lib/sorobanFootprintDiff"; +import { + firstSimulationJson, + invalidXdrSimulationJson, + secondSimulationJson +} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture"; + +describe("runSorobanFootprintDiff", () => { + it("diffs two simulation footprints", async () => { + const result = await runSorobanFootprintDiff({ + firstResult: firstSimulationJson, + secondResult: secondSimulationJson + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.added.map((key) => key.id)).toEqual(["CONTRACT_DATA:D"]); + expect(result.value.removed.map((key) => key.id)).toEqual(["CONTRACT_DATA:C"]); + expect(result.value.modeChanges.some((change) => change.id === "CONTRACT_DATA:B")).toBe(true); + }); + + it("maps invalid JSON", async () => { + expect( + await runSorobanFootprintDiff({ firstResult: "{", secondResult: secondSimulationJson }) + ).toEqual({ ok: false, code: "invalid_json" }); + }); + + it("maps invalid footprint XDR", async () => { + expect( + await runSorobanFootprintDiff({ + firstResult: invalidXdrSimulationJson, + secondResult: secondSimulationJson + }) + ).toEqual({ ok: false, code: "invalid_footprint_xdr" }); + }); +}); diff --git a/features/soroban-footprint-diff/__tests__/useSorobanFootprintDiff.test.tsx b/features/soroban-footprint-diff/__tests__/useSorobanFootprintDiff.test.tsx new file mode 100644 index 0000000..9e6cfb9 --- /dev/null +++ b/features/soroban-footprint-diff/__tests__/useSorobanFootprintDiff.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useSorobanFootprintDiff } from "@/features/soroban-footprint-diff/hooks/useSorobanFootprintDiff"; +import { + firstSimulationJson, + secondSimulationJson +} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture"; + +describe("useSorobanFootprintDiff", () => { + it("starts idle", () => { + const { result } = renderHook(() => useSorobanFootprintDiff()); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("compares footprints", async () => { + const { result } = renderHook(() => useSorobanFootprintDiff()); + await act(async () => { + await result.current.submit({ + firstResult: firstSimulationJson, + secondResult: secondSimulationJson + }); + }); + await waitFor(() => expect(result.current.state.status).toBe("success")); + }); + + it("surfaces empty first result", async () => { + const { result } = renderHook(() => useSorobanFootprintDiff()); + await act(async () => { + await result.current.submit({ firstResult: "", secondResult: secondSimulationJson }); + }); + expect(result.current.state).toEqual({ status: "error", code: "empty_first_result" }); + }); + + it("resets", async () => { + const { result } = renderHook(() => useSorobanFootprintDiff()); + await act(async () => { + await result.current.submit({ + firstResult: firstSimulationJson, + secondResult: secondSimulationJson + }); + }); + await waitFor(() => expect(result.current.state.status).toBe("success")); + act(() => result.current.reset()); + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/features/soroban-footprint-diff/components/AccessChanges.tsx b/features/soroban-footprint-diff/components/AccessChanges.tsx new file mode 100644 index 0000000..c5ab47e --- /dev/null +++ b/features/soroban-footprint-diff/components/AccessChanges.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { copy } from "@/features/soroban-footprint-diff/copy"; +import { formatChangeLine } from "@/features/soroban-footprint-diff/lib/format"; +import type { AccessChange } from "@/features/soroban-footprint-diff/types"; + +export function AccessChanges({ changes }: { changes: AccessChange[] }) { + if (changes.length === 0) { + return

{copy.noModeChanges}

; + } + + return ( +
    + {changes.map((change) => ( +
  • + {formatChangeLine(change)} +
  • + ))} +
+ ); +} diff --git a/features/soroban-footprint-diff/components/FootprintGroups.tsx b/features/soroban-footprint-diff/components/FootprintGroups.tsx new file mode 100644 index 0000000..446d1db --- /dev/null +++ b/features/soroban-footprint-diff/components/FootprintGroups.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { copy } from "@/features/soroban-footprint-diff/copy"; +import { formatKeyLabel } from "@/features/soroban-footprint-diff/lib/format"; +import type { FootprintKey } from "@/features/soroban-footprint-diff/types"; + +export function FootprintGroups({ + added, + removed +}: { + added: FootprintKey[]; + removed: FootprintKey[]; +}) { + return ( +
+
+

{copy.addedTitle}

+ {added.length === 0 ? ( +

{copy.noAdded}

+ ) : ( +
    + {added.map((key) => ( +
  • + {formatKeyLabel(key)} +
  • + ))} +
+ )} +
+
+

{copy.removedTitle}

+ {removed.length === 0 ? ( +

{copy.noRemoved}

+ ) : ( +
    + {removed.map((key) => ( +
  • + {formatKeyLabel(key)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/features/soroban-footprint-diff/components/ResourceSummary.tsx b/features/soroban-footprint-diff/components/ResourceSummary.tsx new file mode 100644 index 0000000..e8a3caf --- /dev/null +++ b/features/soroban-footprint-diff/components/ResourceSummary.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { copy } from "@/features/soroban-footprint-diff/copy"; +import { summarizeResources } from "@/features/soroban-footprint-diff/lib/resource-summary"; +import type { ResourceSummaryData } from "@/features/soroban-footprint-diff/types"; + +export function ResourceSummary({ + first, + second +}: { + first: ResourceSummaryData; + second: ResourceSummaryData; +}) { + const firstRows = summarizeResources(first); + const secondRows = summarizeResources(second); + + return ( +
+

{copy.simulationNote}

+
+
+

{copy.firstResourcesLabel}

+ {firstRows.hasAny ? ( +
    + {firstRows.rows.map((row) => ( +
  • + {row.label}: {row.value} +
  • + ))} +
+ ) : ( +

No resource fields present.

+ )} +
+
+

{copy.secondResourcesLabel}

+ {secondRows.hasAny ? ( +
    + {secondRows.rows.map((row) => ( +
  • + {row.label}: {row.value} +
  • + ))} +
+ ) : ( +

No resource fields present.

+ )} +
+
+
+ ); +} diff --git a/features/soroban-footprint-diff/components/SorobanFootprintDiffEmptyState.tsx b/features/soroban-footprint-diff/components/SorobanFootprintDiffEmptyState.tsx new file mode 100644 index 0000000..e8a6903 --- /dev/null +++ b/features/soroban-footprint-diff/components/SorobanFootprintDiffEmptyState.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { copy } from "@/features/soroban-footprint-diff/copy"; + +export function SorobanFootprintDiffEmptyState() { + return ( + + + {copy.emptyTitle} + +

{copy.emptyDescription}

+
+ ); +} diff --git a/features/soroban-footprint-diff/components/SorobanFootprintDiffForm.tsx b/features/soroban-footprint-diff/components/SorobanFootprintDiffForm.tsx new file mode 100644 index 0000000..c01cc01 --- /dev/null +++ b/features/soroban-footprint-diff/components/SorobanFootprintDiffForm.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { copy } from "@/features/soroban-footprint-diff/copy"; +import type { RawSorobanFootprintDiffForm } from "@/features/soroban-footprint-diff/schema"; + +export function SorobanFootprintDiffForm({ + onSubmit, + pending +}: { + onSubmit: (values: RawSorobanFootprintDiffForm) => void; + pending: boolean; +}) { + const [firstResult, setFirstResult] = useState(""); + const [secondResult, setSecondResult] = useState(""); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit({ firstResult, secondResult }); + } + + return ( +
+

{copy.formHint}

+ + {({ inputId, describedBy, invalid, required }) => ( +