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
40 changes: 40 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<html data-theme="light">`; 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,
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/Layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={["/"]}>
<Layout />
</MemoryRouter>,
);
}

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 <span data-testid="t">{String(theme)}</span>;
}
render(<Probe />);
expect(screen.getByTestId("t")).toHaveTextContent("light");
});
});
39 changes: 38 additions & 1 deletion frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeChoice>(() => {
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]);
Expand All @@ -21,6 +50,14 @@ export function Layout() {
DepRadar
</Link>
<nav className="topbar-meta">
<button
type="button"
className="btn btn-ghost theme-toggle"
aria-label={theme === "light" ? "Switch to dark theme" : "Switch to light theme"}
onClick={() => setTheme(theme === "light" ? null : "light")}
>
{theme === "light" ? "🌙 Dark" : "☀️ Light"}
</button>
<Link to="/about" className="btn btn-ghost">
About
</Link>
Expand Down
13 changes: 8 additions & 5 deletions frontend/src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ export function Identicon({ address, size = 28 }: { address: string; size?: numb
);
}

export function AddressLink({ address, avatar = false }: { address: string; avatar?: boolean }) {
export function AddressLink({ address, avatar = false, copy = false }: { address: string; avatar?: boolean; copy?: boolean }) {
const href = address.startsWith("C") ? explorer.contract(address) : explorer.account(address);
return (
<a className="address" href={href} target="_blank" rel="noreferrer" title={address}>
{avatar && <Identicon address={address} size={20} />}
<span className="mono">{shortAddress(address)}</span>
</a>
<span className="address-wrap">
<a className="address" href={href} target="_blank" rel="noreferrer" title={address}>
{avatar && <Identicon address={address} size={20} />}
<span className="mono">{shortAddress(address)}</span>
</a>
{copy && <CopyButton value={address} />}
</span>
);
}

Expand Down
53 changes: 53 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,38 @@
-webkit-font-smoothing: antialiased;
}

/*
* Light theme (#135). Opt-in via `[data-theme="light"]` on <html>, applied by
* the manual toggle in Layout; without the attribute the dark default holds.
* Only the handful of tokens the palette actually exposes need overriding —
* status hues (teal/amber/red/green) read fine on both.
*/
[data-theme="light"] {
--bg: #f1f5f9;
--bg-2: #e2e8f0;
--surface: rgba(255, 255, 255, 0.85);
--surface-solid: #ffffff;
--surface-hover: rgba(241, 245, 249, 0.95);
--border: rgba(15, 23, 42, 0.12);
--border-strong: rgba(15, 23, 42, 0.24);

--text: #334155;
--text-h: #0f172a;
--muted: #64748b;

--teal-soft: rgba(20, 184, 166, 0.12);
--slate-soft: rgba(15, 23, 42, 0.08);

color-scheme: light;
}

[data-theme="light"] body {
background:
radial-gradient(1200px 600px at 85% -10%, rgba(45, 212, 191, 0.15), transparent 60%),
radial-gradient(900px 500px at -10% 20%, rgba(59, 130, 246, 0.1), transparent 60%),
var(--bg);
}

* {
box-sizing: border-box;
}
Expand Down Expand Up @@ -366,6 +398,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;
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/pages/BountyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) {
</div>
<h1>{text}</h1>
<p className="muted">
Funded by <AddressLink address={bounty.creator} avatar /> on {formatDate(bounty.createdAt)}
Funded by <AddressLink address={bounty.creator} avatar copy /> on {formatDate(bounty.createdAt)}
</p>
</div>
<div className="detail-reward">
Expand Down Expand Up @@ -95,7 +95,7 @@ function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) {
<dd>
{tokenSymbol(bounty.token)}
{bounty.token === NATIVE_XLM_SAC ? <span className="muted"> · native</span> : null}{" "}
<AddressLink address={bounty.token} />
<AddressLink address={bounty.token} copy />
</dd>
</div>
<div>
Expand Down Expand Up @@ -132,14 +132,14 @@ function TimelineItem({ event }: { event: TimelineEvent }) {
<div className="claim-box">
<Identicon address={event.claim.claimant} />
<div>
<AddressLink address={event.claim.claimant} />
<AddressLink address={event.claim.claimant} copy />
<p className="proof">{event.claim.proofRef}</p>
</div>
</div>
)}
{event.kind === "paid" && event.claim && (
<p className="muted">
Sent to <AddressLink address={event.claim.claimant} avatar />
Sent to <AddressLink address={event.claim.claimant} avatar copy />
</p>
)}
</div>
Expand Down
Loading