Skip to content

Commit 5497d7c

Browse files
feat(miner-ui): redesign ledgers route with StateBoundary + skeletons (#6512)
Replace the two hand-rolled loading/error/empty <p> blocks in the ledgers route — the read-only ledger summary and the separate governor-control section — with the shared @loopover/ui-kit StateBoundary, each keeping its own independent boundary so a governor-state fetch failure never blanks the ledger summary (and vice-versa). Content-shaped Skeleton placeholders replace the flat loading text so the layout no longer jumps when the first poll resolves, and the governor-control row is regrouped into a single muted surface container using existing design tokens. lib/ledgers.ts, lib/governor.ts, both fetch loops, and the pause/resume Button wiring are untouched — purely presentational. Closes #6512
1 parent a47d1c3 commit 5497d7c

3 files changed

Lines changed: 147 additions & 77 deletions

File tree

apps/loopover-miner-ui/src/governor.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ describe("defaultGovernorPauseState (#4857)", () => {
3535
});
3636

3737
describe("GovernorControlSection (#4857)", () => {
38-
it("renders the loading state before the first result arrives", () => {
38+
it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6512)", () => {
3939
render(
4040
<GovernorControlSection result={null} pending={false} onPause={() => undefined} onResume={() => undefined} />,
4141
);
42-
expect(screen.getByText(/Loading governor state/i)).toBeTruthy();
42+
expect(screen.getByRole("status", { name: /loading governor state/i })).toBeTruthy();
43+
expect(screen.queryByText("Loading governor state…")).toBeNull(); // the pre-#6512 sentence is gone
4344
});
4445

4546
it("renders an error message when the local API is unreachable", () => {

apps/loopover-miner-ui/src/ledgers.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ describe("LedgersView (#4855)", () => {
112112
expect(screen.getByRole("alert").textContent).toContain("connection refused");
113113
});
114114

115-
it("renders the loading state before the first result arrives", () => {
115+
it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6512)", () => {
116116
render(<LedgersView result={null} />);
117-
expect(screen.getByText(/Loading local ledgers/i)).toBeTruthy();
117+
expect(screen.getByRole("status", { name: /loading local ledgers/i })).toBeTruthy();
118+
expect(screen.queryByText("Loading local ledgers…")).toBeNull(); // the pre-#6512 sentence is gone
118119
});
119120
});
120121

apps/loopover-miner-ui/src/routes/ledgers.tsx

Lines changed: 141 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import { useEffect, useState } from "react";
44
import { Button } from "@loopover/ui-kit/components/button";
55
import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card";
66
import { Input } from "@loopover/ui-kit/components/input";
7+
import { Skeleton } from "@loopover/ui-kit/components/skeleton";
8+
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
79
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table";
810

9-
import { CLAIM_STATUSES, fetchLedgers, type ClaimStatus, type LedgersResult } from "../lib/ledgers";
11+
import {
12+
CLAIM_STATUSES,
13+
fetchLedgers,
14+
type ClaimStatus,
15+
type LedgersResult,
16+
type LedgersSummary,
17+
} from "../lib/ledgers";
1018
import { fetchGovernorPauseState, pauseGovernor, resumeGovernor, type GovernorPauseStateResult } from "../lib/governor";
1119

1220
export const Route = createFileRoute("/ledgers")({
@@ -15,8 +23,15 @@ export const Route = createFileRoute("/ledgers")({
1523

1624
// Read-only views over the miner's local claim / event / governor ledgers (#4855). All three are aggregated
1725
// server-side (see vite-ledgers-api.ts) to status/type counts plus a small feed of SAFE columns — raw payloads
18-
// and the free-text claim note never reach this component. Same 4-state pattern as the portfolio/run-history
19-
// views (loading / error / fresh-install empty / populated).
26+
// and the free-text claim note never reach this component.
27+
//
28+
// #6512: the two hand-rolled loading/error/empty `<p>` blocks (the read-only ledger summary, and the SEPARATE
29+
// governor control section) are each replaced by the shared @loopover/ui-kit `StateBoundary`, with a
30+
// content-shaped `Skeleton` placeholder for the loading state so the layout doesn't jump when the poll resolves.
31+
// The two flows keep their OWN independent boundary — a governor-state fetch failure must not blank the ledger
32+
// summary, and vice-versa. Purely presentational: `lib/ledgers.ts`/`lib/governor.ts`, the two fetch loops, and
33+
// the pause/resume Button wiring are untouched; only the loading/error chrome and the governor-control layout
34+
// change.
2035
//
2136
// The governor control section below is a SEPARATE fetch/action loop from the read-only ledger summary above
2237
// (#4857, the governor half): it reads/writes the governor's pause state via vite-governor-api.ts, the
@@ -57,81 +72,54 @@ function CountTable({ counts, keyLabel }: { counts: Record<string, number>; keyL
5772
);
5873
}
5974

60-
export function GovernorControlSection({
61-
result,
62-
pending,
63-
onPause,
64-
onResume,
65-
}: {
66-
result: GovernorPauseStateResult | null;
67-
pending: boolean;
68-
onPause: (reason?: string) => void;
69-
onResume: () => void;
70-
}) {
71-
// Optional pause reason, mirroring the CLI's `governor pause [--reason <text>]`; an empty field
72-
// is passed through as `undefined` so it matches the CLI's own optional-flag behavior.
73-
const [reason, setReason] = useState("");
75+
/** Card+table-shaped loading placeholder for the ledger summary: mirrors the 3 status cards and the stacked
76+
* count/feed tables below them, so the summary keeps its shape while the first fetch resolves. `role="status"`
77+
* keeps the loading state announced to assistive tech (as the flat "Loading local ledgers…" text it replaces
78+
* was). */
79+
function LedgerSummarySkeleton() {
7480
return (
75-
<section className="grid gap-3">
76-
<h3 className="font-display text-token-base font-semibold">Governor control</h3>
77-
{result === null ? (
78-
<p className="text-token-sm text-muted-foreground">Loading governor state…</p>
79-
) : !result.ok ? (
80-
<p role="alert" className="text-token-sm text-[var(--danger)]">
81-
Could not read the local governor state: {result.error}
82-
</p>
83-
) : (
84-
<div className="flex flex-wrap items-center gap-3">
85-
<p className="text-token-sm text-muted-foreground">
86-
{result.pauseState.paused
87-
? `Paused since ${result.pauseState.pausedAt}${result.pauseState.reason ? ` (${result.pauseState.reason})` : ""}`
88-
: "Not paused"}
89-
</p>
90-
{result.pauseState.paused ? (
91-
<Button size="sm" variant="outline" disabled={pending} onClick={onResume}>
92-
Resume governor
93-
</Button>
94-
) : (
95-
<>
96-
<Input
97-
type="text"
98-
value={reason}
99-
onChange={(event) => setReason(event.target.value)}
100-
disabled={pending}
101-
placeholder="Reason (optional)"
102-
aria-label="Pause reason"
103-
className="w-auto flex-1 min-w-[12rem]"
104-
/>
105-
<Button size="sm" variant="destructive" disabled={pending} onClick={() => onPause(reason || undefined)}>
106-
Pause governor
107-
</Button>
108-
</>
109-
)}
81+
<div className="grid gap-6" role="status" aria-label="Loading local ledgers">
82+
<section className="grid gap-3">
83+
<Skeleton className="h-5 w-28" />
84+
<div className="grid gap-4 sm:grid-cols-3">
85+
{Array.from({ length: 3 }).map((_, index) => (
86+
<Card key={index}>
87+
<CardContent className="p-4">
88+
<Skeleton className="h-3 w-16" />
89+
<Skeleton className="mt-2 h-8 w-12" />
90+
</CardContent>
91+
</Card>
92+
))}
11093
</div>
111-
)}
112-
</section>
94+
</section>
95+
{Array.from({ length: 2 }).map((_, index) => (
96+
<section key={index} className="grid gap-3">
97+
<Skeleton className="h-5 w-40" />
98+
<Skeleton className="h-24 w-full" />
99+
</section>
100+
))}
101+
</div>
113102
);
114103
}
115104

116-
export function LedgersView({ result }: { result: LedgersResult | null }) {
117-
if (result === null) {
118-
return <p className="text-token-sm text-muted-foreground">Loading local ledgers…</p>;
119-
}
120-
if (!result.ok) {
121-
return (
122-
<p role="alert" className="text-token-sm text-[var(--danger)]">
123-
Could not read the local ledgers: {result.error}
124-
</p>
125-
);
126-
}
127-
const { claims, events, governor } = result.summary;
128-
if (claims.total === 0 && events.total === 0 && governor.total === 0) {
129-
return (
130-
<p className="text-token-sm text-muted-foreground">
131-
No ledger activity yet — claims, events, and governor entries appear here once the miner starts working.
132-
</p>
133-
);
134-
}
105+
/** Row-shaped loading placeholder for the governor control section: a status line plus an action-sized block,
106+
* matching the "Not paused / Resume" row it stands in for. Its own `role="status"` announces this flow
107+
* independently of the ledger-summary skeleton above. */
108+
function GovernorControlSkeleton() {
109+
return (
110+
<div
111+
className="flex flex-wrap items-center gap-3 rounded-token-sm bg-muted/40 p-3"
112+
role="status"
113+
aria-label="Loading governor state"
114+
>
115+
<Skeleton className="h-5 w-40" />
116+
<Skeleton className="h-9 w-28" />
117+
</div>
118+
);
119+
}
120+
121+
function LedgersSummaryContent({ summary }: { summary: LedgersSummary }) {
122+
const { claims, events, governor } = summary;
135123
return (
136124
<div className="grid gap-6">
137125
<section className="grid gap-3">
@@ -199,6 +187,86 @@ export function LedgersView({ result }: { result: LedgersResult | null }) {
199187
);
200188
}
201189

190+
export function GovernorControlSection({
191+
result,
192+
pending,
193+
onPause,
194+
onResume,
195+
}: {
196+
result: GovernorPauseStateResult | null;
197+
pending: boolean;
198+
onPause: (reason?: string) => void;
199+
onResume: () => void;
200+
}) {
201+
// Optional pause reason, mirroring the CLI's `governor pause [--reason <text>]`; an empty field
202+
// is passed through as `undefined` so it matches the CLI's own optional-flag behavior.
203+
const [reason, setReason] = useState("");
204+
const errorText = result !== null && !result.ok ? result.error : undefined;
205+
return (
206+
<section className="grid gap-3">
207+
<h3 className="font-display text-token-base font-semibold">Governor control</h3>
208+
<StateBoundary
209+
isLoading={result === null}
210+
isError={result !== null && !result.ok}
211+
loadingSkeleton={<GovernorControlSkeleton />}
212+
errorTitle="Couldn't read the local governor state"
213+
errorDescription={errorText}
214+
>
215+
{result?.ok && (
216+
<div className="flex flex-wrap items-center gap-3 rounded-token-sm bg-muted/40 p-3">
217+
<p className="text-token-sm text-muted-foreground">
218+
{result.pauseState.paused
219+
? `Paused since ${result.pauseState.pausedAt}${result.pauseState.reason ? ` (${result.pauseState.reason})` : ""}`
220+
: "Not paused"}
221+
</p>
222+
{result.pauseState.paused ? (
223+
<Button size="sm" variant="outline" disabled={pending} onClick={onResume} className="ml-auto">
224+
Resume governor
225+
</Button>
226+
) : (
227+
<div className="ml-auto flex flex-wrap items-center gap-3">
228+
<Input
229+
type="text"
230+
value={reason}
231+
onChange={(event) => setReason(event.target.value)}
232+
disabled={pending}
233+
placeholder="Reason (optional)"
234+
aria-label="Pause reason"
235+
className="w-auto flex-1 min-w-[12rem]"
236+
/>
237+
<Button size="sm" variant="destructive" disabled={pending} onClick={() => onPause(reason || undefined)}>
238+
Pause governor
239+
</Button>
240+
</div>
241+
)}
242+
</div>
243+
)}
244+
</StateBoundary>
245+
</section>
246+
);
247+
}
248+
249+
export function LedgersView({ result }: { result: LedgersResult | null }) {
250+
const summary = result?.ok ? result.summary : null;
251+
const isEmpty =
252+
summary !== null && summary.claims.total === 0 && summary.events.total === 0 && summary.governor.total === 0;
253+
const errorText = result !== null && !result.ok ? result.error : undefined;
254+
return (
255+
<StateBoundary
256+
isLoading={result === null}
257+
isError={result !== null && !result.ok}
258+
isEmpty={isEmpty}
259+
loadingSkeleton={<LedgerSummarySkeleton />}
260+
errorTitle="Couldn't read the local ledgers"
261+
errorDescription={errorText}
262+
emptyTitle="No ledger activity yet"
263+
emptyDescription="Claims, events, and governor entries appear here once the miner starts working."
264+
>
265+
{summary && <LedgersSummaryContent summary={summary} />}
266+
</StateBoundary>
267+
);
268+
}
269+
202270
export function LedgersPage({
203271
loadLedgers = fetchLedgers,
204272
loadGovernorPauseState = fetchGovernorPauseState,

0 commit comments

Comments
 (0)