From dff80e675c590f369760d1339f0c9edf56a2533d Mon Sep 17 00:00:00 2001 From: guelowrd <102294720+guelowrd@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:57:46 +0100 Subject: [PATCH 1/3] feat: Overview cards say what they count Every stat card carries the same info tip the status card rows had, so a newcomer can read what Accounts, Assets, Activity and the three lifecycle counts mean without leaving the page. Activity says "confirmed transactions, all time", lists Discarded, and names the proposals row apart from the transaction states. Assets drops the cryptic parenthetical for a sub-line and expands into priced and unpriced token counts. The lifecycle labels name accounts, and the Accounts expander names its split. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017aUkMPsKoKHZ21s3YvsYxL --- __tests__/components/ActivityCard.test.tsx | 25 ++++ __tests__/components/AssetsCard.test.tsx | 16 ++- components/accounts/StatStrip.tsx | 2 +- components/overview/AccountsCard.tsx | 7 +- components/overview/ActivityCard.tsx | 20 +-- components/overview/AssetsCard.tsx | 134 +++++++++++++-------- components/overview/AttentionCards.tsx | 29 ++++- components/overview/GuardianStatusCard.tsx | 20 +-- components/ui/InfoTip.tsx | 20 +++ 9 files changed, 191 insertions(+), 82 deletions(-) create mode 100644 __tests__/components/ActivityCard.test.tsx create mode 100644 components/ui/InfoTip.tsx diff --git a/__tests__/components/ActivityCard.test.tsx b/__tests__/components/ActivityCard.test.tsx new file mode 100644 index 0000000..de7ffe0 --- /dev/null +++ b/__tests__/components/ActivityCard.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ActivityCard } from "@/components/overview/ActivityCard"; + +vi.mock("swr", () => ({ default: vi.fn() })); + +const useSWR = (await import("swr")).default as ReturnType; + +beforeEach(() => vi.clearAllMocks()); + +describe("ActivityCard", () => { + it("says what the headline counts and lists every state, proposals named apart", () => { + useSWR.mockReturnValue({ + data: { deltaStatusCounts: { candidate: 0, canonical: 717, discarded: 4, retained: 15 }, inFlightProposalCount: 2 }, + }); + render(); + expect(screen.getByText("717")).toBeInTheDocument(); + expect(screen.getByText("confirmed transactions, all time")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Expand" })); + for (const label of ["Confirmed", "Submitted", "Recovering", "Discarded", "Proposals awaiting signatures"]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(screen.getByText("4")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/AssetsCard.test.tsx b/__tests__/components/AssetsCard.test.tsx index d634050..571393d 100644 --- a/__tests__/components/AssetsCard.test.tsx +++ b/__tests__/components/AssetsCard.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { AssetsCard } from "@/components/overview/AssetsCard"; vi.mock("swr", () => ({ default: vi.fn() })); @@ -46,7 +46,7 @@ describe("AssetsCard", () => { mockData({ usd7d: null, computedAt: "2026-10-07T12:00:00Z", priced: 0, unpriced: 104 }); render(); expect(screen.getByText("Unpriced")).toBeInTheDocument(); - expect(screen.getByTitle(/104 faucet/)).toBeInTheDocument(); + expect(screen.getByTitle(/104 token/)).toBeInTheDocument(); expect(screen.queryByText("—")).not.toBeInTheDocument(); }); @@ -100,3 +100,15 @@ describe("AssetsCard poll scheduling", () => { expect(optionsFromCall(0).refreshInterval).toBe(60_000); }); }); + +describe("AssetsCard expander", () => { + it("expands into the priced and unpriced token counts", () => { + mockData({ usd7d: 2.32, computedAt: "2026-10-08T10:00:00Z", priced: 1, unpriced: 3 }); + render(); + expect(screen.getByText(/held by accounts updated in the last 7d/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Expand" })); + expect(screen.getByText("Priced tokens")).toBeInTheDocument(); + expect(screen.getByText("Unpriced tokens")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); +}); diff --git a/components/accounts/StatStrip.tsx b/components/accounts/StatStrip.tsx index c485710..7091bb3 100644 --- a/components/accounts/StatStrip.tsx +++ b/components/accounts/StatStrip.tsx @@ -91,7 +91,7 @@ export function StatStrip() { )} {assets?.usd7d != null && ( - Assets (updated last 7d)  ${assets.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + Assets (accounts updated last 7d)  ${assets.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} )} diff --git a/components/overview/AccountsCard.tsx b/components/overview/AccountsCard.tsx index 51959d7..43ec648 100644 --- a/components/overview/AccountsCard.tsx +++ b/components/overview/AccountsCard.tsx @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { ChevronDown, ChevronUp } from "lucide-react"; import { fetcher } from "@/lib/utils"; import { formatCount } from "@/lib/format"; +import { InfoTip } from "@/components/ui/InfoTip"; interface OverviewData { totalAccounts: number; @@ -36,7 +37,10 @@ export function AccountsCard() {
-

Accounts

+

+ Accounts + +

{loading ? ( ) : ( @@ -58,6 +62,7 @@ export function AccountsCard() {
{expanded && data && (
+

By signature scheme

{data.falcon === null ? (

-

Activity

+

+ Activity + +

{loading ? ( ) : ( @@ -46,7 +50,7 @@ export function ActivityCard() {

)} {data && ( -

confirmed

+

confirmed transactions, all time

)}
{data && ( @@ -72,11 +76,6 @@ export function ActivityCard() { value={data.deltaStatusCounts.candidate} accent={data.deltaStatusCounts.candidate > 0 ? "text-state-pending" : undefined} /> - 0 ? "text-state-pending" : undefined} - /> {/* Shown only when there are any. A permanent "Recovering 0" adds a row of noise to every Guardian that never has one, and Guardians below 0.16.1 do not report this at all. */} @@ -87,6 +86,13 @@ export function ActivityCard() { accent="text-state-pending" /> )} + + {/* The one row that is not a transaction state. */} + 0 ? "text-state-pending" : undefined} + />
)} diff --git a/components/overview/AssetsCard.tsx b/components/overview/AssetsCard.tsx index ec84d00..8f5c971 100644 --- a/components/overview/AssetsCard.tsx +++ b/components/overview/AssetsCard.tsx @@ -1,8 +1,12 @@ "use client"; +import { useState } from "react"; import useSWR from "swr"; +import { ChevronDown, ChevronUp } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { fetcher } from "@/lib/utils"; +import { formatCount } from "@/lib/format"; +import { InfoTip } from "@/components/ui/InfoTip"; type AssetTotals = { usd7d?: number | null; @@ -25,59 +29,93 @@ export function AssetsCard() { const { data, error } = useSWR("/api/accounts/asset-totals", fetcher, { refreshInterval: 60_000, }); + const [expanded, setExpanded] = useState(false); + const split = data?.priced != null && data.unpriced != null; return ( -

Assets (updated last 7d)

- {!data && !error ? ( - - ) : data?.usd7d != null ? ( -

- ${data.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -

- ) : data?.unsupported ? ( - // Names the reason rather than showing the same dash a dead Guardian - // would. Nothing here will change until this Guardian's operator - // upgrades, so there is no progress to report. -

- Needs Guardian 0.18.0 -

- ) : data?.unpriced && !data.priced ? ( - // Holdings exist, and none of them has a market. Priced the way the - // Miden wallet does (lib/prices.ts): a test mint or an unlisted token - // gets no dollar figure rather than an invented one. -

- Unpriced -

- ) : data?.warming ? ( - // A 0.18.0 Guardian that has not finished its first pass, or one whose - // pass could not decode every vault. It says how far it got, and the - // number climbing is the only evidence that waiting will end. -

- Calculating… - {data.done != null && data.total != null && ( - - {data.done.toLocaleString()} of {data.total.toLocaleString()} - +

+
+

+ Assets + +

+ {!data && !error ? ( + + ) : data?.usd7d != null ? ( +

+ ${data.usd7d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+ ) : data?.unsupported ? ( + // Names the reason rather than showing the same dash a dead Guardian + // would. Nothing here will change until this Guardian's operator + // upgrades, so there is no progress to report. +

+ Needs Guardian 0.18.0 +

+ ) : data?.unpriced && !data.priced ? ( + // Holdings exist, and none of them has a market. Priced the way the + // Miden wallet does (lib/prices.ts): a test mint or an unlisted token + // gets no dollar figure rather than an invented one. +

+ Unpriced +

+ ) : data?.warming ? ( + // A 0.18.0 Guardian that has not finished its first pass, or one whose + // pass could not decode every vault. It says how far it got, and the + // number climbing is the only evidence that waiting will end. +

+ Calculating… + {data.done != null && data.total != null && ( + + {data.done.toLocaleString()} of {data.total.toLocaleString()} + + )} +

+ ) : ( +

+ — +

)} -

- ) : ( -

- — -

+ {split && ( +

held by accounts updated in the last 7d

+ )} +
+ {split && ( + + )} +
+ {expanded && split && ( +
+
+ Priced tokens + {formatCount(data.priced!)} +
+
+ Unpriced tokens + {formatCount(data.unpriced!)} +
+
)}
diff --git a/components/overview/AttentionCards.tsx b/components/overview/AttentionCards.tsx index e445127..11d9ee7 100644 --- a/components/overview/AttentionCards.tsx +++ b/components/overview/AttentionCards.tsx @@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Timestamp } from "@/components/ui/Timestamp"; import { fetcher } from "@/lib/utils"; import { formatCount } from "@/lib/format"; +import { InfoTip } from "@/components/ui/InfoTip"; import { STATS_KEY, type AccountStats } from "@/components/accounts/StatStrip"; /** @@ -23,11 +24,13 @@ import { STATS_KEY, type AccountStats } from "@/components/accounts/StatStrip"; function Stat({ icon, label, + info, children, tone = "quiet", }: { icon: React.ReactNode; label: string; + info: string; children: React.ReactNode; tone?: "quiet" | "attention"; }) { @@ -37,6 +40,7 @@ function Stat({

{icon} {label} +

{children} @@ -63,16 +67,33 @@ export function AttentionCards() { return (
- } label="Active"> + } + label="Active accounts" + info="Accounts this Guardian signs for, neither frozen nor released." + > {count(stats?.active, "/accounts?state=active")} - } label="Released"> + } + label="Released accounts" + info="Accounts moved to another Guardian. This one no longer signs for them." + > {count(stats?.released, "/accounts?state=released")} - } label="Frozen" tone={stats?.frozen ? "attention" : "quiet"}> + } + label="Frozen accounts" + info="Accounts paused by the operator. Nothing is signed for them until they are unfrozen." + tone={stats?.frozen ? "attention" : "quiet"} + > {count(stats?.frozen, "/accounts?paused=true")} - } label="Last activity"> + } + label="Last activity" + info="When this Guardian last recorded a transaction or a proposal." + > {overview === undefined ? ( ) : overview.latestActivity ? ( diff --git a/components/overview/GuardianStatusCard.tsx b/components/overview/GuardianStatusCard.tsx index cb9bcd4..c988c9f 100644 --- a/components/overview/GuardianStatusCard.tsx +++ b/components/overview/GuardianStatusCard.tsx @@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { LineChart, Line, ResponsiveContainer, Tooltip } from "recharts"; import { ChevronDown, ChevronUp, AlertTriangle, Check, Info } from "lucide-react"; +import { InfoTip } from "@/components/ui/InfoTip"; import { fetcher } from "@/lib/utils"; import { formatCount } from "@/lib/format"; import { truncateId } from "@/lib/format"; @@ -142,25 +143,6 @@ function CopyableHash({ value, short }: { value: string; short?: boolean }) { ); } -// An icon rather than a 9px letter in a circle. 9px was the smallest type in -// the product by a wide margin and well under anything legible; drawing the -// glyph sidesteps the problem instead of shrinking text to fit a 14px circle. -// -// `title` carries the same text, so it survives for anyone not using a mouse: -// the hover-only tooltip was unreachable by keyboard or touch. -function InfoTip({ text }: { text: string }) { - return ( - - - {text} - - - {text} - - - ); -} - function Row({ label, value, sub, info }: { label: string; value: React.ReactNode; sub?: React.ReactNode; info?: string }) { return (
diff --git a/components/ui/InfoTip.tsx b/components/ui/InfoTip.tsx new file mode 100644 index 0000000..26c3dfb --- /dev/null +++ b/components/ui/InfoTip.tsx @@ -0,0 +1,20 @@ +import { Info } from "lucide-react"; + +// An icon rather than a 9px letter in a circle. 9px was the smallest type in +// the product by a wide margin and well under anything legible; drawing the +// glyph sidesteps the problem instead of shrinking text to fit a 14px circle. +// +// `title` carries the same text, so it survives for anyone not using a mouse: +// the hover-only tooltip was unreachable by keyboard or touch. +export function InfoTip({ text }: { text: string }) { + return ( + + + {text} + + + {text} + + + ); +} From 24fde8c2971c6289f40c83c3540d7c286cd45443 Mon Sep 17 00:00:00 2001 From: guelowrd <102294720+guelowrd@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:10:07 +0100 Subject: [PATCH 2/3] fix: a card no longer clips the info tip above its first row The shared Card clipped its overflow, which cut every tooltip opening upward from a label at the top of a card. Nothing in a card needs the clip: the tables sit inside their own scrolling content box and no card holds an image. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017aUkMPsKoKHZ21s3YvsYxL --- components/ui/card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/card.tsx b/components/ui/card.tsx index d3bbd84..5345f13 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -12,7 +12,7 @@ function Card({ data-slot="card" data-size={size} className={cn( - "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + "group/card flex flex-col gap-4 rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className )} {...props} From 8c71f7cb41b5f29ae2dd456374cee55958f47a58 Mon Sep 17 00:00:00 2001 From: guelowrd <102294720+guelowrd@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:06:05 +0100 Subject: [PATCH 3/3] fix: the Guardian acknowledges transactions, it does not sign House verbiage: three Overview tooltips said "signs for" and the proposal label said "n/m signed". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017aUkMPsKoKHZ21s3YvsYxL --- __tests__/lib/format.test.ts | 4 ++-- components/overview/AttentionCards.tsx | 6 +++--- components/transactions/activity-cells.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/__tests__/lib/format.test.ts b/__tests__/lib/format.test.ts index 13a0d18..d5f8dd7 100644 --- a/__tests__/lib/format.test.ts +++ b/__tests__/lib/format.test.ts @@ -14,8 +14,8 @@ describe("activityToCsv", () => { }); it("leaves the counterparty and amount blank for a proposal", () => { - const row = activityToCsv([{ accountId: "0xabc", label: "Swap", status: "1/2 signed", timestamp: "t" }]).split("\r\n")[1]; - expect(row).toBe("0xabc,,,Swap,,1/2 signed,t"); + const row = activityToCsv([{ accountId: "0xabc", label: "Swap", status: "1/2 acknowledged", timestamp: "t" }]).split("\r\n")[1]; + expect(row).toBe("0xabc,,,Swap,,1/2 acknowledged,t"); }); }); diff --git a/components/overview/AttentionCards.tsx b/components/overview/AttentionCards.tsx index 11d9ee7..e1a1d4d 100644 --- a/components/overview/AttentionCards.tsx +++ b/components/overview/AttentionCards.tsx @@ -70,21 +70,21 @@ export function AttentionCards() { } label="Active accounts" - info="Accounts this Guardian signs for, neither frozen nor released." + info="Accounts this Guardian acknowledges transactions for, neither frozen nor released." > {count(stats?.active, "/accounts?state=active")} } label="Released accounts" - info="Accounts moved to another Guardian. This one no longer signs for them." + info="Accounts moved to another Guardian. This one no longer acknowledges their transactions." > {count(stats?.released, "/accounts?state=released")} } label="Frozen accounts" - info="Accounts paused by the operator. Nothing is signed for them until they are unfrozen." + info="Accounts paused by the operator. No transaction is acknowledged for them until they are unfrozen." tone={stats?.frozen ? "attention" : "quiet"} > {count(stats?.frozen, "/accounts?paused=true")} diff --git a/components/transactions/activity-cells.tsx b/components/transactions/activity-cells.tsx index a142cd4..bb828d6 100644 --- a/components/transactions/activity-cells.tsx +++ b/components/transactions/activity-cells.tsx @@ -113,7 +113,7 @@ export function deltaStatusLabel(status: string): string { } export function proposalStatusLabel(collected: number, required: number): string { - return `${collected}/${required} signed`; + return `${collected}/${required} acknowledged`; } export function deltaStatusBadge(status: string, statusReason?: string) {