From dfbb15827a0fedee933731c044727c389ad8b41d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 1 Nov 2026 21:54:30 +0800 Subject: [PATCH 1/2] frontend: search bounties by finding text (#122) Case-insensitive substring search over issueRef and the structured package name, debounced 150ms into the ?q= URL param, with a clear button and a no-matches message. Includes BountyList component tests (6). --- PROGRESS.md | 14 +++ frontend/src/index.css | 21 ++++ frontend/src/pages/BountyList.test.tsx | 129 +++++++++++++++++++++++++ frontend/src/pages/BountyList.tsx | 55 ++++++++++- 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/BountyList.test.tsx diff --git a/PROGRESS.md b/PROGRESS.md index c955003..519cd9d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,20 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). ## Entries +### Bounty board search by finding text — #122 + +- **What:** Adds a search box to the bounty board: case-insensitive substring + match over each bounty's issue reference and structured package name + (`bountyPackage`), debounced 150 ms into the `q` URL parameter (shareable + views, composable with the status filter), a clear button, and a dedicated + no-matches message. +- **Tests:** New `src/pages/BountyList.test.tsx` covers empty-query pass-through, + debouncing, case-insensitive matching by reference and by package name, the + no-matches message and the clear button. Full frontend suite: 56 passing. +- **By:** Hermes agent +- **Closes:** #122 +- **Status:** Ready for review. + ### Dependency licenses and sources — `cargo deny` — #163 - **What:** Adds [`deny.toml`](deny.toml) allowing MIT, Apache-2.0, BSD, diff --git a/frontend/src/index.css b/frontend/src/index.css index 5f3c0ca..adf64fc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -366,6 +366,27 @@ button:hover { flex-wrap: wrap; margin-bottom: 1.25rem; } +.board-search { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.board-search input[type="search"] { + background: var(--slate-soft); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-h); + font: inherit; + padding: 0.35rem 0.9rem; + min-width: 14rem; +} + +.board-search input[type="search"]:focus { + outline: none; + border-color: rgba(45, 212, 191, 0.5); +} + .chips { display: flex; gap: 0.4rem; diff --git a/frontend/src/pages/BountyList.test.tsx b/frontend/src/pages/BountyList.test.tsx new file mode 100644 index 0000000..636360e --- /dev/null +++ b/frontend/src/pages/BountyList.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BountyList } from "./BountyList"; +import type { Bounty } from "../lib/types"; +import * as contract from "../lib/contract"; + +vi.mock("../lib/contract", () => ({ + listBounties: vi.fn(), +})); +vi.mock("../lib/typosquat", () => ({ + bountyPackage: (b: Pick) => b.package ?? null, +})); +// The Radar canvas isn't the subject here. +vi.mock("../components/Radar", () => ({ Radar: () => null })); + +const bounty = (id: number, issueRef: string, pkg?: string): Bounty => ({ + id, + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + issueRef, + package: pkg, + amount: 5_000_000n, + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + status: "Open", + createdAt: 1_791_546_147, + deadline: 1_794_138_138, + claimCount: 0, + pendingClaims: 0, +}); + +const bounties = [ + bounty(1, "expresss typosquat of express", "expresss"), + bounty(2, "requetss typosquat of requests", "requetss"), + bounty(3, "lodash prototype pollution", "lodash"), +]; + +function renderList() { + return render( + + + , + ); +} + +const searchBox = () => screen.getByRole("searchbox", { name: /search bounties/i }); + +/** Types into the search box and lets the 150ms debounce elapse inside act(). */ +function search(text: string) { + act(() => { + fireEvent.change(searchBox(), { target: { value: text } }); + vi.advanceTimersByTime(150); + }); +} + +beforeEach(() => { + // Fake timers with advancing real time: the debounce is testable via + // advanceTimersByTime, while async contract resolves still settle. + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.mocked(contract.listBounties).mockResolvedValue(bounties); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("BountyList search", () => { + it("shows every bounty when the query is empty", async () => { + renderList(); + expect(await screen.findByText("expresss typosquat of express")).toBeInTheDocument(); + expect(screen.getByText("requetss typosquat of requests")).toBeInTheDocument(); + expect(screen.getByText("lodash prototype pollution")).toBeInTheDocument(); + }); + + it("debounces input before filtering", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + act(() => { + fireEvent.change(searchBox(), { target: { value: "lodash" } }); + }); + // Not yet applied before the debounce elapses. + expect(screen.getByText("expresss typosquat of express")).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(screen.getByText("lodash prototype pollution")).toBeInTheDocument(); + expect(screen.queryByText("expresss typosquat of express")).not.toBeInTheDocument(); + }); + + it("filters case-insensitively by issue reference text", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("LODASH"); + expect(screen.getByText("lodash prototype pollution")).toBeInTheDocument(); + expect(screen.queryByText("expresss typosquat of express")).not.toBeInTheDocument(); + }); + + it("filters by package name", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("requets"); + expect(screen.getByText("requetss typosquat of requests")).toBeInTheDocument(); + expect(screen.queryByText("lodash prototype pollution")).not.toBeInTheDocument(); + }); + + it("shows a no-matches message", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("zzzznope"); + expect(screen.getByText(/no bounties match/i)).toBeInTheDocument(); + }); + + it("clears the search with the clear button", async () => { + renderList(); + await screen.findByText("expresss typosquat of express"); + search("lodash"); + expect(screen.queryByText("expresss typosquat of express")).not.toBeInTheDocument(); + act(() => { + fireEvent.click(screen.getByRole("button", { name: /clear search/i })); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(screen.getByText("expresss typosquat of express")).toBeInTheDocument(); + expect(screen.queryByText(/no bounties match/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/BountyList.tsx b/frontend/src/pages/BountyList.tsx index c6e7398..5bc207d 100644 --- a/frontend/src/pages/BountyList.tsx +++ b/frontend/src/pages/BountyList.tsx @@ -1,5 +1,7 @@ import { useSearchParams } from "react-router-dom"; +import { useEffect, useState } from "react"; import { BountyCard, BountyCardSkeleton } from "../components/BountyCard"; +import { bountyPackage } from "../lib/typosquat"; import { Radar } from "../components/Radar"; import { Empty, ErrorState, Skeleton } from "../components/ui"; import { listBounties } from "../lib/contract"; @@ -22,6 +24,34 @@ export function BountyList() { const bounties = state.status === "success" ? state.data : []; const shown = filter.status ? bounties.filter((b) => b.status === filter.status) : bounties; + // Search by finding text (#122): case-insensitive substring over the package + // name and issue reference. The input is debounced (150ms) into the `q` + // URL param so the view stays shareable without re-rendering every keystroke. + const urlQuery = params.get("q") ?? ""; + const [input, setInput] = useState(urlQuery); + useEffect(() => { + const t = setTimeout(() => { + const next = new URLSearchParams(params); + if (input) next.set("q", input); + else next.delete("q"); + if (next.toString() !== params.toString()) setParams(next, { replace: true }); + }, 150); + return () => clearTimeout(t); + }, [input, params, setParams]); + useEffect(() => { + // Keep the input in sync when the URL changes externally (back/forward + // navigation, shared links with ?q=). Deliberate sync-in-effect. + setInput(urlQuery); + }, [urlQuery]); + const query = urlQuery.trim().toLowerCase(); + const searched = query + ? shown.filter( + (b) => + b.issueRef.toLowerCase().includes(query) || + (bountyPackage(b) ?? "").toLowerCase().includes(query), + ) + : shown; + return ( <>
@@ -44,6 +74,25 @@ export function BountyList() {

Bounty board

+
e.preventDefault()}> + setInput(e.target.value)} + /> + {input && ( + + )} +
{FILTERS.map((f) => { const count = f.status ? bounties.filter((b) => b.status === f.status).length : bounties.length; @@ -73,7 +122,7 @@ export function BountyList() { )} {state.status === "error" && } {state.status === "success" && - (shown.length === 0 ? ( + (searched.length === 0 ? ( bounties.length === 0 ? (
@@ -95,12 +144,14 @@ export function BountyList() {
+ ) : query ? ( + {`No bounties match “${urlQuery.trim()}”${filter.status ? ` in ${filter.label.toLowerCase()}` : ""}.`} ) : ( {`No ${filter.label.toLowerCase()} bounties right now.`} ) ) : (
- {shown.map((b) => ( + {searched.map((b) => ( ))}
From 1825e6d7221f15b7cfa14b9f5a7cdfa867153645 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 2 Nov 2026 00:16:29 +0800 Subject: [PATCH 2/2] frontend: copy-to-clipboard on addresses (#133), manual light/dark theme toggle (#135) - AddressLink gains a copy prop rendering a CopyButton next to the address; enabled on funder, token contract and claimant addresses. - useTheme hook + topbar toggle switching , persisted to localStorage, dark default, private-mode safe. Light token overrides in index.css. 6 new tests; suite 62/62. Closes #133, closes #135 --- PROGRESS.md | 26 +++++++++ frontend/src/components/Layout.test.tsx | 78 +++++++++++++++++++++++++ frontend/src/components/Layout.tsx | 39 ++++++++++++- frontend/src/components/ui.tsx | 13 +++-- frontend/src/index.css | 32 ++++++++++ frontend/src/pages/BountyDetail.tsx | 8 +-- 6 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/Layout.test.tsx diff --git a/PROGRESS.md b/PROGRESS.md index 519cd9d..31e8c17 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,32 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). ## Entries +### Copy-to-clipboard on addresses — #133 + +- **What:** `AddressLink` gains an optional `copy` prop that renders a + `CopyButton` next to the address. Enabled on the funder address (bounty + detail header), the token contract address, and the claimant address in + timeline claim/paid entries. +- **Tests:** Existing `ui.test.tsx` covers CopyButton; address rendering + unchanged when `copy` is not set. +- **By:** Hermes agent (Fuma2514) +- **Closes:** #133 +- **Status:** Ready for review. + +### Manual light/dark theme toggle — #135 + +- **What:** `useTheme` hook in Layout: a `☀️/🌙` toggle in the topbar switches + ``; a `[data-theme="light"]` token override block + in `index.css` re-skins the palette. Choice persists in + `localStorage["depradar-theme"]`, dark remains the default (no attribute), + and a throwing `localStorage` (private mode) degrades to session-only. +- **Tests:** New `Layout.test.tsx` (6 tests): default dark, switch+persist, + restore on remount, toggle back, throwing-storage fallback, hook read. + Frontend suite: 62/62 passing across 10 files. +- **By:** Hermes agent (Fuma2514) +- **Closes:** #135 +- **Status:** Ready for review. + ### Bounty board search by finding text — #122 - **What:** Adds a search box to the bounty board: case-insensitive substring diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx new file mode 100644 index 0000000..cf420e6 --- /dev/null +++ b/frontend/src/components/Layout.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Layout, useTheme } from "./Layout"; + +function renderLayout() { + return render( + + + , + ); +} + +const toggle = () => screen.getByRole("button", { name: /theme/i }); + +beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + vi.restoreAllMocks(); +}); + +describe("Layout theme toggle", () => { + it("defaults to dark (no data-theme attribute)", () => { + renderLayout(); + expect(document.documentElement).not.toHaveAttribute("data-theme"); + }); + + it("switches to light and sets the data-theme attribute", () => { + renderLayout(); + fireEvent.click(toggle()); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + expect(localStorage.getItem("depradar-theme")).toBe("light"); + }); + + it("persists the choice and restores it on the next mount", () => { + renderLayout(); + fireEvent.click(toggle()); + cleanup(); + renderLayout(); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + }); + + it("toggles back to dark and clears the stored choice", () => { + renderLayout(); + fireEvent.click(toggle()); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + fireEvent.click(toggle()); + expect(document.documentElement).not.toHaveAttribute("data-theme"); + expect(localStorage.getItem("depradar-theme")).toBeNull(); + }); + + it("survives a throwing localStorage (private mode)", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("denied"); + }); + renderLayout(); + fireEvent.click(toggle()); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + }); +}); + +describe("useTheme", () => { + it("reads a stored theme on mount", () => { + localStorage.setItem("depradar-theme", "light"); + function Probe() { + const [theme] = useTheme(); + return {String(theme)}; + } + render(); + expect(screen.getByTestId("t")).toHaveTextContent("light"); + }); +}); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9c57ffa..c31e83a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,10 +1,39 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link, Outlet, useLocation } from "react-router-dom"; import { CONTRACT_ID, NETWORK, explorer } from "../config"; import { shortAddress } from "../lib/format"; +/** The dashboard's stored theme, `null` until the user picks one manually. */ +export type ThemeChoice = "light" | "dark" | null; + +const THEME_KEY = "depradar-theme"; + +/** Manual theme toggle (#135): persisted to localStorage, dark by default. */ +// eslint-disable-next-line react-refresh/only-export-components -- oxlint fast-refresh warning; hook shares the theme key with Layout +export function useTheme() { + const [theme, setTheme] = useState(() => { + try { + return localStorage.getItem(THEME_KEY) as ThemeChoice; + } catch { + return null; + } + }); + useEffect(() => { + if (theme === "light") document.documentElement.setAttribute("data-theme", "light"); + else document.documentElement.removeAttribute("data-theme"); + try { + if (theme) localStorage.setItem(THEME_KEY, theme); + else localStorage.removeItem(THEME_KEY); + } catch { + /* private mode: keep the choice for the session only */ + } + }, [theme]); + return [theme, setTheme] as const; +} + export function Layout() { const { pathname } = useLocation(); + const [theme, setTheme] = useTheme(); useEffect(() => { window.scrollTo(0, 0); }, [pathname]); @@ -21,6 +50,14 @@ export function Layout() { DepRadar

{text}

- Funded by on {formatDate(bounty.createdAt)} + Funded by on {formatDate(bounty.createdAt)}

@@ -95,7 +95,7 @@ function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) {
{tokenSymbol(bounty.token)} {bounty.token === NATIVE_XLM_SAC ? · native : null}{" "} - +
@@ -132,14 +132,14 @@ function TimelineItem({ event }: { event: TimelineEvent }) {
- +

{event.claim.proofRef}

)} {event.kind === "paid" && event.claim && (

- Sent to + Sent to

)}