diff --git a/docs/components.md b/docs/components.md
index 506beca..a39974a 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -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";
+
+
+```
+
+**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.
diff --git a/docs/pr/spotkorner-dot-296-297-298-299.md b/docs/pr/spotkorner-dot-296-297-298-299.md
new file mode 100644
index 0000000..a74476f
--- /dev/null
+++ b/docs/pr/spotkorner-dot-296-297-298-299.md
@@ -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
diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts
new file mode 100644
index 0000000..349aa7f
--- /dev/null
+++ b/e2e/command-palette.spec.ts
@@ -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$/);
+});
diff --git a/src/app/explore/ExplorePageClient.test.tsx b/src/app/explore/ExplorePageClient.test.tsx
new file mode 100644
index 0000000..15ce1f6
--- /dev/null
+++ b/src/app/explore/ExplorePageClient.test.tsx
@@ -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();
+ 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();
+ 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();
+ });
+});
diff --git a/src/app/explore/ExplorePageClient.tsx b/src/app/explore/ExplorePageClient.tsx
index 10af3f1..e544143 100644
--- a/src/app/explore/ExplorePageClient.tsx
+++ b/src/app/explore/ExplorePageClient.tsx
@@ -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";
@@ -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("all");
const [chainFilter, setChainFilter] = useState("all");
const [sort, setSort] = useState("newest");
@@ -150,7 +152,7 @@ export default function ExplorePageClient() {
- {timeAgo(item.createdAt)}
+ {timeAgo(item.createdAt, now)}
))}
diff --git a/src/app/explore/[id]/page.test.tsx b/src/app/explore/[id]/page.test.tsx
index fac4e69..94c85bb 100644
--- a/src/app/explore/[id]/page.test.tsx
+++ b/src/app/explore/[id]/page.test.tsx
@@ -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";
@@ -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();
+
+ 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();
+
+ 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();
+
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ });
});
diff --git a/src/app/explore/[id]/page.tsx b/src/app/explore/[id]/page.tsx
index 88cfb04..93a9a19 100644
--- a/src/app/explore/[id]/page.tsx
+++ b/src/app/explore/[id]/page.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Nav } from "@/components/Nav";
import { Footer } from "@/components/Footer";
+import { CopyButton } from "@/components/CopyButton";
import { IntentStatusBadge } from "@/components/IntentStatusBadge";
import { SkeletonDetailCard } from "@/components/Skeleton";
import { useIntent } from "@/hooks/useIntent";
@@ -27,19 +28,33 @@ function deadlineLabel(deadline: string) {
export default function IntentDetailPage({ params }: { params: { id: string } }) {
const { intent, isLoading, error } = useIntent(params.id);
+ const { copy } = useCopyToClipboard();
const isExpired = useMemo(() => {
if (!intent || intent.status !== "pending" || !intent.deadline) return false;
return new Date(intent.deadline).getTime() <= Date.now();
}, [intent]);
+ const isSettled = intent?.status === "filled";
return (
-
- ← Back to explorer
-
+
+
+ ← Back to explorer
+
+ {intent && (
+
+ )}
+
{isLoading ? (
@@ -52,7 +67,15 @@ export default function IntentDetailPage({ params }: { params: { id: string } })
No details found for this intent.
) : (
-
+
+ {/* Print-only header - the on-screen Nav/Footer are stripped when printing. */}
+