diff --git a/PROGRESS.md b/PROGRESS.md index c955003..31e8c17 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,46 @@ 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 + 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/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