Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,36 @@ import { ConnectWalletButton } from "@/components/ConnectWalletButton";
- On a failed `connect()` call, it also pushes an error toast via
[`useToastStore`](../src/store/toast.ts) — you don't need to handle connection
errors yourself when using this component.

## `CommandPalette`

[`src/components/CommandPalette.tsx`](../src/components/CommandPalette.tsx)

Global `Cmd/Ctrl+K` command palette for keyboard-driven navigation. Mounted once
in [`src/app/layout.tsx`](../src/app/layout.tsx) so the shortcut works on every
route — you do not render it yourself.

```tsx
import { CommandPalette } from "@/components/CommandPalette";

<CommandPalette />
```

**Props**

None. State (open/closed, query, active option) is entirely internal.

**Behavior**

- `Cmd/Ctrl+K` toggles the palette open/closed from anywhere. `Escape` or a
click on the backdrop closes it.
- Lists the four top-level routes (`/`, `/explore`, `/solve`, `/my-intents`),
filtered by the typed query against each route's label/path.
- If the query is a valid Stellar public key it offers a direct jump to
`/solve/[address]`; otherwise a whitespace-free token that matches no route is
offered as an `/explore/[id]` lookup.
- Fully keyboard-operable: `ArrowUp`/`ArrowDown` move the active option (wrapping),
`Enter` activates it, following the WAI-ARIA combobox/listbox pattern
(`role="combobox"` input + `role="listbox"` with `aria-activedescendant`).
- On activate it calls `router.push(href)` and restores focus to the element that
was focused before the palette opened.
117 changes: 117 additions & 0 deletions docs/pr/spotkorner-dot-296-297-298-299.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
## Summary

Four UX enhancements for the Vortex frontend, one commit each:

- **#298** – `Cmd/Ctrl+K` command palette for keyboard navigation
- **#299** – relative timestamps that stay current in live list views
- **#296** – printable / save-as-PDF summary on the intent detail page
- **#297** – "quote changed" delta indicator on the swap card

Closes #296
Closes #297
Closes #298
Closes #299

## Changes

### #298 – Command palette (`Cmd/Ctrl+K`)
- New `src/components/CommandPalette.tsx`: global `keydown` listener toggles a
WAI-ARIA combobox/listbox modal. Navigates the four top-level routes; a pasted
Stellar public key routes to `/solve/[address]`, any other whitespace-free
token that matches no route is offered as an `/explore/[id]` lookup. Full
keyboard operation (arrows wrap, `Enter` activates, `Esc`/backdrop close),
focus is moved in on open and restored on close.
- Mounted once in `src/app/layout.tsx`.
- `src/components/CommandPalette.test.tsx` (8 cases), `e2e/command-palette.spec.ts`,
and a `docs/components.md` entry.
- Strings are hard-coded English, matching the existing `ExplorePageClient`
convention and avoiding edits to the (currently out-of-sync) i18n catalogs.

### #299 – Live relative timestamps
- New `src/hooks/useLiveRelativeTime.ts`: one shared 45s interval returning a
`now` timestamp; pauses on `visibilitychange` (same pattern as `useWebSocket`)
and clears on unmount. One interval per list, not one timer per row.
- Applied in `ActivityFeed.tsx`, `ExplorePageClient.tsx`, and `my-intents/page.tsx`
(`timeAgo(iso, now)`), the latter gaining a "submitted … ago" line per row.
- `useLiveRelativeTime.test.ts` and `ExplorePageClient.test.tsx` (new), the
latter asserting the label advances on its own as time passes.

### #296 – Printable intent record
- `explore/[id]/page.tsx`: a `print:hidden` "Print / Save as PDF" button calling
`window.print()`; the summary card is wrapped as `#intent-record` with a
print-only header; any non-`filled` intent shows a "not a completed-swap
record" notice so a mid-flight print can't be mistaken for a receipt. The
"Submitted" field now shows an absolute timestamp.
- New `@media print` block in `src/app/globals.css` strips `nav`/`footer` and
interactive chrome and renders the record black-on-white.
- The status badge already carries a text label + distinct icon shape, so it
stays legible in greyscale.

### #297 – Quote-change delta indicator
- `SwapCard.tsx` tracks the immediately-previous quote for the *same route*
(chain + token pair) in a ref. When a fresh same-route quote moves the output
amount or price impact, a small ▲/▼ badge (green = better for the user, amber =
worse) shows next to that field and fades after 4s. No delta on the first
quote for a route or after a token/route change.
- `SwapCard.delta.test.tsx` (new, 4 cases): improves / worsens / first-quote /
route-change.
- Two missing catalog keys (`swap.quote.noSolver`, `swap.quote.highPriceImpactWarning`)
added to `en`/`es`.

## Testing

- [ ] `npm run build` – **blocked by pre-existing breakage** (see below)
- [ ] `npx tsc --noEmit` – **blocked by pre-existing breakage** (see below)
- [x] New tests pass in isolation:
- `CommandPalette.test.tsx` 8/8
- `useLiveRelativeTime.test.ts` 3/3, `ExplorePageClient.test.tsx` 2/2
- `explore/[id]/page.test.tsx` 11/11 (was 0/8), `my-intents/page.test.tsx` 18/19 (was 0/17)
- `ActivityFeed.test.tsx` 10/10 (was 0/10)
- `SwapCard.test.tsx` 14/14 (was 0/14, file didn't collect), `SwapCard.delta.test.tsx` 4/4
- [x] Full suite moved from **72 failed / 231 total** to **55 failed / 283 total**
(52 new tests added, all green; 17 pre-existing failures fixed as a side effect
of repairing files these features touch).

### Pre-existing breakage (not introduced here)

`main` does not build, typecheck, lint, or pass its own test suite at
`c87dc14`. Root cause: PRs #217/#218/#219 were merged with `Merge branch main
into feature/…` conflict resolutions that kept both sides, leaving duplicate
declarations, half-applied features, and test files from divergent branches.

To ship these four features the following files had to be repaired **just enough
to compile and render** (their existing test suites are exercised above):

- `src/components/SwapCard.tsx` – had ~42 type errors (duplicate `chainPickerRef`/
`chainToggleRef`/`closeChainPicker`/`useEffect`; undefined `dstAddress`,
`slippagePct`, `quoteFetchedAt`, `STALE_QUOTE_THRESHOLD_MS`, `quoteErrorType`).
The dropped slippage-tolerance field + min-out line were restored (both have
`en`/`es` keys and are required by the existing `SwapCard.test.tsx`); the
amount input is now `type="text" inputMode="decimal"` so 18-dp values aren't
reformatted.
- `src/components/ActivityFeed.tsx` – duplicate `export`, undefined
`useTranslation`/`announcement`/`FeedSkeleton`/`ActivityFeedView`; rebuilt
without i18n (matching `ExplorePageClient`) and with the debounced live-region
announcement its test expects.
- `src/app/explore/[id]/page.tsx` – `CopyButton`/`copy`/`copied` undefined.
- `src/app/my-intents/page.tsx` – `downloadCsv`/`buildIntentsCsv` not imported,
duplicate status badge, over-riding `aria-label`s.

Still red and **left untouched** (out of scope): the 3 files with unresolved
merge-conflict markers that block a full `tsc`/`build`
(`src/app/explore/page.tsx`, `src/app/solve/page.tsx`,
`src/app/solve/[address]/page.test.tsx`); `Nav.tsx` / `ConnectWalletButton.tsx` /
`wallet.ts` (mocked in the touched test suites); the i18n catalog key-parity
gap; `*.stories.tsx`. `my-intents/page.test.tsx`'s "retry button" case references
undefined `user`/`mutateMock` in the test body and cannot pass without a test
rewrite.

Print-preview screenshots for #296 could not be captured because the app does
not currently run; the print trigger is covered by a mocked `window.print` test.

## Checklist

- [x] Self-reviewed the diff
- [x] Added or updated tests for new behaviour
- [x] No secrets or credentials committed
- [x] PR title follows conventional commits
30 changes: 30 additions & 0 deletions e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from "@playwright/test";

// Exercises the Cmd/Ctrl+K command palette end to end: open with the shortcut,
// filter, and navigate. No wallet or backend is needed - the palette is static
// navigation only.
test("command palette: open with the shortcut and jump to a route", async ({ page }) => {
await page.goto("/");

// Ctrl+K works cross-platform in Chromium; Meta+K is the macOS equivalent.
await page.keyboard.press("Control+K");

const palette = page.getByRole("dialog", { name: "Command palette" });
await expect(palette).toBeVisible();

await page.getByRole("combobox").fill("explore");
await page.getByRole("option", { name: /Explore intents/ }).click();

await expect(page).toHaveURL(/\/explore$/);
await expect(palette).toBeHidden();
});

test("command palette: paste an intent id to open its detail page", async ({ page }) => {
await page.goto("/");
await page.keyboard.press("Control+K");

await page.getByRole("combobox").fill("intent-1");
await page.getByRole("option", { name: /Open intent/ }).click();

await expect(page).toHaveURL(/\/explore\/intent-1$/);
});
55 changes: 55 additions & 0 deletions src/app/explore/ExplorePageClient.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import type { FeedItem } from "@/lib/types";

const { useLiveIntentsMock } = vi.hoisted(() => ({ useLiveIntentsMock: vi.fn() }));
vi.mock("@/hooks/useLiveIntents", () => ({ useLiveIntents: useLiveIntentsMock }));
// Nav/Footer pull in wallet + i18n context this suite does not set up.
vi.mock("@/components/Nav", () => ({ Nav: () => null }));
vi.mock("@/components/Footer", () => ({ Footer: () => null }));

import ExplorePageClient from "./ExplorePageClient";

const intents: FeedItem[] = [
{
id: "1",
srcChain: "ethereum",
srcToken: "USDC",
srcAmount: "500",
dstToken: "USDC",
solver: "Alpha",
status: "filled",
createdAt: new Date("2026-07-14T00:00:00Z").toISOString(),
},
];

describe("ExplorePageClient", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-07-14T00:00:30Z"));
useLiveIntentsMock.mockReturnValue({ intents, isLoading: false, error: undefined, isLive: true });
});

afterEach(() => {
vi.useRealTimers();
});

it("renders an intent row with a relative timestamp", () => {
render(<ExplorePageClient />);
expect(screen.getByText("500 USDC → USDC")).toBeInTheDocument();
expect(screen.getByText("30s ago")).toBeInTheDocument();
});

it("advances the relative timestamp on its own as time passes", () => {
render(<ExplorePageClient />);
expect(screen.getByText("30s ago")).toBeInTheDocument();

act(() => {
vi.setSystemTime(new Date("2026-07-14T00:02:00Z"));
vi.advanceTimersByTime(45_000);
});

expect(screen.getByText("2m ago")).toBeInTheDocument();
expect(screen.queryByText("30s ago")).not.toBeInTheDocument();
});
});
4 changes: 3 additions & 1 deletion src/app/explore/ExplorePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Footer } from "@/components/Footer";
import { IntentStatusBadge } from "@/components/IntentStatusBadge";
import { SkeletonCard } from "@/components/Skeleton";
import { useLiveIntents } from "@/hooks/useLiveIntents";
import { useLiveRelativeTime } from "@/hooks/useLiveRelativeTime";
import { timeAgo } from "@/lib/time";
import { CHAINS } from "@/lib/marketData";
import type { IntentStatus } from "@/lib/types";
Expand All @@ -18,6 +19,7 @@ const PAGE_SIZE = 10;

export default function ExplorePageClient() {
const { intents, isLoading, error, isLive } = useLiveIntents();
const now = useLiveRelativeTime();
const [statusFilter, setStatusFilter] = useState<IntentStatus | "all">("all");
const [chainFilter, setChainFilter] = useState<string>("all");
const [sort, setSort] = useState<SortOption>("newest");
Expand Down Expand Up @@ -150,7 +152,7 @@ export default function ExplorePageClient() {
</div>
<IntentStatusBadge status={item.status} />
<span className="text-xs text-vx-muted num flex-shrink-0 w-16 text-right">
{timeAgo(item.createdAt)}
{timeAgo(item.createdAt, now)}
</span>
</Link>
))}
Expand Down
33 changes: 33 additions & 0 deletions src/app/explore/[id]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { IntentDetail } from "@/lib/types";

const { useIntentMock } = vi.hoisted(() => ({ useIntentMock: vi.fn() }));
vi.mock("@/hooks/useIntent", () => ({ useIntent: useIntentMock }));
// Nav/Footer are app chrome that needs wallet + i18n context this suite does
// not set up - stub them so the record itself is what's under test.
vi.mock("@/components/Nav", () => ({ Nav: () => null }));
vi.mock("@/components/Footer", () => ({ Footer: () => null }));

import IntentDetailPage from "./page";

Expand Down Expand Up @@ -83,4 +88,32 @@ describe("IntentDetailPage", () => {

expect(screen.getByText("← Back to explorer")).toHaveAttribute("href", "/explore");
});

it("triggers the browser print dialog from the Print / Save as PDF action", async () => {
const printSpy = vi.spyOn(window, "print").mockImplementation(() => {});
useIntentMock.mockReturnValue({ intent: detail, isLoading: false, error: undefined });
render(<IntentDetailPage params={{ id: "intent-1" }} />);

await userEvent.click(screen.getByRole("button", { name: /print \/ save as pdf/i }));
expect(printSpy).toHaveBeenCalledTimes(1);
printSpy.mockRestore();
});

it("marks a non-settled intent as not a completed-swap record", () => {
useIntentMock.mockReturnValue({
intent: { ...detail, status: "pending", txHash: undefined },
isLoading: false,
error: undefined,
});
render(<IntentDetailPage params={{ id: "intent-1" }} />);

expect(screen.getByRole("note")).toHaveTextContent(/not yet settled/i);
});

it("shows a completed record with no warning for a filled intent", () => {
useIntentMock.mockReturnValue({ intent: detail, isLoading: false, error: undefined });
render(<IntentDetailPage params={{ id: "intent-1" }} />);

expect(screen.queryByRole("note")).not.toBeInTheDocument();
});
});
Loading