From 2fe15499e35d4d0c53c022acd58fe7fce38194ba Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:06:28 -0300 Subject: [PATCH 01/12] feat(app): give each admin surface its own tab --- app/src/App.test.tsx | 9 + app/src/App.tsx | 1 + .../components/admin/SurfaceTabs.module.css | 33 +++ app/src/components/admin/SurfaceTabs.test.tsx | 41 ++++ app/src/components/admin/SurfaceTabs.tsx | 26 +++ app/src/pages/AdminPanel.module.css | 44 ++-- app/src/pages/AdminPanel.test.tsx | 193 ++++++++++++------ app/src/pages/AdminPanel.tsx | 95 +++++---- 8 files changed, 318 insertions(+), 124 deletions(-) create mode 100644 app/src/components/admin/SurfaceTabs.module.css create mode 100644 app/src/components/admin/SurfaceTabs.test.tsx create mode 100644 app/src/components/admin/SurfaceTabs.tsx diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index e0cecae..6cf9ce1 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -72,4 +72,13 @@ describe("App", () => { ).toBeTruthy() expect(screen.getByText("GitHub")).toBeTruthy() }) + + it("serves the admin panel at a surface path too", () => { + useAdminAuthorityMock.mockReturnValue(disconnectedAuthority) + renderAt("/admin/cycle") + + expect( + screen.getByText("Connect a wallet to operate this vault."), + ).toBeTruthy() + }) }) diff --git a/app/src/App.tsx b/app/src/App.tsx index 537f32e..3fde5b2 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ function App() { }> } /> } /> + } /> ) diff --git a/app/src/components/admin/SurfaceTabs.module.css b/app/src/components/admin/SurfaceTabs.module.css new file mode 100644 index 0000000..dbceec3 --- /dev/null +++ b/app/src/components/admin/SurfaceTabs.module.css @@ -0,0 +1,33 @@ +.tabs { + display: flex; + gap: 10px; + margin-inline: -6px; + overflow-x: auto; + scrollbar-width: thin; + box-shadow: inset 0 -1px 0 var(--vault-border-hairline); +} + +.tab { + flex: none; + padding: 4px 6px 10px; + white-space: nowrap; + color: var(--vault-text-body); + text-decoration: none; + border-bottom: 2px solid transparent; +} + +.tab:hover { + color: var(--vault-ink); + border-bottom-color: var(--vault-border-inactive); +} + +.tab:focus-visible { + outline: 2px solid var(--vault-action); + outline-offset: -2px; +} + +.active, +.active:hover { + color: var(--vault-ink); + border-bottom-color: var(--vault-ink); +} diff --git a/app/src/components/admin/SurfaceTabs.test.tsx b/app/src/components/admin/SurfaceTabs.test.tsx new file mode 100644 index 0000000..b4a2aef --- /dev/null +++ b/app/src/components/admin/SurfaceTabs.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, within } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" +import { describe, expect, it } from "vitest" +import SurfaceTabs from "./SurfaceTabs" + +const renderAt = (path: string) => + render( + + + , + ) + +describe("SurfaceTabs", () => { + it("links each given surface under the admin path, in the given order", () => { + renderAt("/admin/cycle") + + const nav = screen.getByRole("navigation", { name: "Admin surfaces" }) + const links = within(nav).getAllByRole("link") + expect(links.map((link) => link.textContent)).toEqual([ + "Cycle", + "Emergency", + ]) + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/admin/cycle", + "/admin/emergency", + ]) + }) + + it("marks only the surface at the current path as the current page", () => { + renderAt("/admin/emergency") + + expect( + screen + .getByRole("link", { name: "Emergency" }) + .getAttribute("aria-current"), + ).toBe("page") + expect( + screen.getByRole("link", { name: "Cycle" }).getAttribute("aria-current"), + ).toBeNull() + }) +}) diff --git a/app/src/components/admin/SurfaceTabs.tsx b/app/src/components/admin/SurfaceTabs.tsx new file mode 100644 index 0000000..83605eb --- /dev/null +++ b/app/src/components/admin/SurfaceTabs.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { NavLink } from "react-router-dom" +import { + type AdminSurface, + SURFACE_LABELS, +} from "../../hooks/useAdminAuthority" +import typeStyles from "../../styles/type.module.css" +import styles from "./SurfaceTabs.module.css" + +const SurfaceTabs: React.FC<{ surfaces: AdminSurface[] }> = ({ surfaces }) => ( + +) + +export default SurfaceTabs diff --git a/app/src/pages/AdminPanel.module.css b/app/src/pages/AdminPanel.module.css index 1354f08..2ae3568 100644 --- a/app/src/pages/AdminPanel.module.css +++ b/app/src/pages/AdminPanel.module.css @@ -5,39 +5,39 @@ padding: 32px 0; } -.cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +.pending { + display: flex; + flex-direction: column; gap: 14px; - margin: 0; - padding: 0; - list-style: none; } -.card { - display: flex; - flex-direction: column; - gap: 8px; - padding: 20px; - border: 1px solid var(--vault-ink); - background: var(--vault-surface-card); +.pendingLine { + height: 1.2em; + width: 60%; } -.cardWide { - grid-column: 1 / -1; +.surfaces { + display: flex; + flex-direction: column; + gap: 18px; } -.cardHeading { - color: var(--vault-ink); +.surface { + display: flex; + flex-direction: column; + gap: 14px; } -.cardNote { - color: var(--vault-text-muted); +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); } -.cardSkeleton { - height: 1.2em; - width: 60%; +.srOnly:focus { + outline: none; } .addresses { diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index ad3ef9b..2c5ee83 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, within } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" +import { MemoryRouter, Route, Routes } from "react-router-dom" import { beforeEach, describe, expect, it, vi } from "vitest" import type * as UseAdminAuthority from "../hooks/useAdminAuthority" import { type AdminAuthority, type Grant } from "../hooks/useAdminAuthority" @@ -50,14 +51,29 @@ const authority = (overrides: Partial): AdminAuthority => ({ ...overrides, }) +const renderAt = (path: string) => + render( + + + } /> + } /> + + , + ) + +const currentTab = () => + screen + .getAllByRole("link") + .find((link) => link.getAttribute("aria-current") === "page")?.textContent + describe("AdminPanel", () => { beforeEach(() => { useWalletMock.mockReturnValue({ address: connectedAddress }) }) - it("tells a disconnected wallet to connect, with no cards and no identity line", () => { + it("tells a disconnected wallet to connect, with no tabs and no identity line", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "disconnected" })) - render() + renderAt("/admin") expect( screen.getByText("Connect a wallet to operate this vault."), @@ -68,7 +84,7 @@ describe("AdminPanel", () => { it("shows a pending skeleton while checking, with no identity line", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "checking" })) - render() + renderAt("/admin") expect(screen.getAllByRole("progressbar")).toHaveLength(2) expect(screen.queryByText(/GCON/)).toBeNull() @@ -76,7 +92,7 @@ describe("AdminPanel", () => { it("reports when the vault's authorities could not be read, with no identity line", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "unreadable" })) - render() + renderAt("/admin") expect( screen.getByText("Could not read the vault's authorities."), @@ -95,7 +111,7 @@ describe("AdminPanel", () => { }, }), ) - render() + renderAt("/admin") expect( screen.getByText("This address holds no authority on this vault."), @@ -105,7 +121,15 @@ describe("AdminPanel", () => { expect(screen.getByText("GCON...7890")).toBeTruthy() }) - it("renders a card per held surface, in Cycle · Compliance · Emergency · Configuration · Governance order", () => { + it("keeps the page-level states free of surface tabs", () => { + useAdminAuthorityMock.mockReturnValue(authority({ status: "checking" })) + renderAt("/admin/cycle") + + expect(screen.queryByRole("navigation")).toBeNull() + expect(screen.queryAllByRole("heading", { level: 2 })).toHaveLength(0) + }) + + it("tabs only the held surfaces, in Cycle · Compliance · Emergency · Configuration · Governance order", () => { const manager = holderOf("vault manager") const guardian = holderOf("vault guardian") const oracleGuardian = holderOf("oracle guardian") @@ -123,17 +147,15 @@ describe("AdminPanel", () => { grants: [manager, guardian, oracleGuardian, governance], }), ) - render() + renderAt("/admin/emergency") - const headings = screen.getAllByRole("heading", { level: 2 }) - expect(headings.map((heading) => heading.textContent)).toEqual([ - "Cycle", - "Emergency", - "Governance", - ]) + const nav = screen.getByRole("navigation", { name: "Admin surfaces" }) + expect(nav.textContent).toBe("CycleEmergencyGovernance") + expect(currentTab()).toBe("Emergency") expect( - screen.getByText("Granted by vault guardian, oracle guardian"), - ).toBeTruthy() + screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent), + ).toEqual(["Emergency"]) + expect(screen.queryByText(/Granted by/)).toBeNull() expect( screen.queryByText("This address holds no authority on this vault."), ).toBeNull() @@ -144,7 +166,92 @@ describe("AdminPanel", () => { ).toBeTruthy() }) - it("names the signature standing on the identity line and on the cards", () => { + it("opens the first held surface at /admin", () => { + const guardian = holderOf("vault guardian") + const governance = holderOf("vault governance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["governance", "emergency"]), + grantedBy: { + ...emptyGrantedBy, + emergency: [guardian], + governance: [governance], + }, + grants: [guardian, governance], + }), + ) + renderAt("/admin") + + expect(currentTab()).toBe("Emergency") + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe( + "Emergency", + ) + }) + + it("redirects a surface the wallet does not hold to the first held one, showing nothing of it", () => { + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["compliance"]), + grantedBy: { ...emptyGrantedBy, compliance: [compliance] }, + grants: [compliance], + }), + ) + renderAt("/admin/cycle") + + expect(currentTab()).toBe("Compliance") + expect(screen.queryByRole("heading", { name: "Cycle" })).toBeNull() + expect(screen.queryByText(/Cycle surface/)).toBeNull() + }) + + it("redirects an unknown surface to the first held one", () => { + const manager = holderOf("vault manager") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle"]), + grantedBy: { ...emptyGrantedBy, cycle: [manager] }, + grants: [manager], + }), + ) + renderAt("/admin/treasury") + + expect(currentTab()).toBe("Cycle") + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Cycle") + }) + + it("moves focus to the surface heading when the tab changes, not on first load", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + renderAt("/admin") + + expect(document.activeElement).not.toBe( + screen.getByRole("heading", { name: "Cycle" }), + ) + + fireEvent.click(screen.getByRole("link", { name: "Compliance" })) + + expect(currentTab()).toBe("Compliance") + expect(document.activeElement).toBe( + screen.getByRole("heading", { name: "Compliance" }), + ) + }) + + it("names the signature standing of every grant on the identity line", () => { const governance: Grant = { role: "vault governance", authority: "GGOVERNANCEMULTISIG", @@ -173,27 +280,16 @@ describe("AdminPanel", () => { grants: [attester, compliance, governance], }), ) - render() + renderAt("/admin") expect( screen.getByText( "GCON...7890 · oracle attester (1 of 2 signatures) · share-token compliance (weight 1 of threshold 3) · vault governance (signs alone)", ), ).toBeTruthy() - expect( - screen.getByText("Granted by vault governance — signs alone"), - ).toBeTruthy() - expect( - screen.getByText("Granted by oracle attester — 1 of 2 signatures"), - ).toBeTruthy() - expect( - screen.getByText( - "Granted by share-token compliance — weight 1 of threshold 3", - ), - ).toBeTruthy() }) - it("hands the Cycle card every grant that opened it, so its actions follow the wallet's roles", () => { + it("hands the Cycle surface every grant that opened it, so its actions follow the wallet's roles", () => { const manager = holderOf("vault manager") const treasury = holderOf("vault treasury") useAdminAuthorityMock.mockReturnValue( @@ -204,7 +300,7 @@ describe("AdminPanel", () => { grants: [manager, treasury], }), ) - render() + renderAt("/admin/cycle") expect( screen.getByText("Cycle surface for vault manager, vault treasury"), @@ -221,9 +317,8 @@ describe("AdminPanel", () => { grants: [manager], }), ) - render() + renderAt("/admin") - expect(screen.getByText("Granted by vault manager")).toBeTruthy() expect(screen.getByText("GCON...7890 · vault manager")).toBeTruthy() expect(screen.queryByText("Signer lookup unavailable.")).toBeNull() }) @@ -242,7 +337,7 @@ describe("AdminPanel", () => { grants: [governance], }), ) - render() + renderAt("/admin") expect( screen.getByText("GCON...7890 · vault governance (1 of 2 signatures)"), @@ -253,12 +348,12 @@ describe("AdminPanel", () => { useAdminAuthorityMock.mockReturnValue( authority({ status: "ready", signersUnknown: true }), ) - render() + renderAt("/admin") expect(screen.getAllByText("Signer lookup unavailable.")).toHaveLength(1) }) - it("shows the cycle surface inside the Cycle card, and only there", () => { + it("shows the cycle surface only on the Cycle tab", () => { const manager = holderOf("vault manager") const compliance = holderOf("share-token compliance") useAdminAuthorityMock.mockReturnValue( @@ -273,30 +368,12 @@ describe("AdminPanel", () => { grants: [manager, compliance], }), ) - render() + renderAt("/admin/compliance") - const cards = screen.getAllByRole("listitem") - const cycleCard = cards.find( - (card) => within(card).queryByRole("heading", { name: "Cycle" }) !== null, - ) - expect( - within(cycleCard!).getByText("Cycle surface for vault manager"), - ).toBeTruthy() - expect(screen.getAllByText(/Cycle surface/)).toHaveLength(1) - }) + expect(screen.queryByText(/Cycle surface/)).toBeNull() - it("shows no cycle surface to a wallet with no Cycle grant", () => { - const compliance = holderOf("share-token compliance") - useAdminAuthorityMock.mockReturnValue( - authority({ - status: "ready", - surfaces: new Set(["compliance"]), - grantedBy: { ...emptyGrantedBy, compliance: [compliance] }, - grants: [compliance], - }), - ) - render() + fireEvent.click(screen.getByRole("link", { name: "Cycle" })) - expect(screen.queryByText("Cycle surface")).toBeNull() + expect(screen.getAllByText(/Cycle surface/)).toHaveLength(1) }) }) diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index f1c558a..13b48f4 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -1,11 +1,14 @@ import { shortAddress } from "@stellar-scaffold/app-lib" -import React from "react" +import React, { useEffect, useRef } from "react" +import { Navigate, useParams } from "react-router-dom" import CycleSection from "../components/admin/CycleSection" +import SurfaceTabs from "../components/admin/SurfaceTabs" import Skeleton from "../components/Skeleton" import AddressRows, { type AddressRow } from "../components/vault/AddressRows" import { vaultContractId } from "../config/contracts" import { type AdminAddresses, + type AdminSurface, type Grant, type Standing, SURFACE_LABELS, @@ -37,11 +40,6 @@ const describeGrant = (grant: Grant, wallet: string): string => ? grant.role : `${grant.role} (${describeStanding(grant.standing)})` -const describeCardGrant = (grant: Grant, wallet: string): string => - isSingleKey(grant, wallet) - ? grant.role - : `${grant.role} — ${describeStanding(grant.standing)}` - const AddressesBlock: React.FC<{ addresses: AdminAddresses }> = ({ addresses, }) => ( @@ -53,10 +51,46 @@ const AddressesBlock: React.FC<{ addresses: AdminAddresses }> = ({ ) +const HeldSurfaces: React.FC<{ + held: AdminSurface[] + grantedBy: Record + wallet: string +}> = ({ held, grantedBy, wallet }) => { + const { surface } = useParams() + const active = held.find((candidate) => candidate === surface) + const headingRef = useRef(null) + const shownSurface = useRef(undefined) + + useEffect(() => { + if (active === undefined) return + if (shownSurface.current !== undefined && shownSurface.current !== active) { + headingRef.current?.focus() + } + shownSurface.current = active + }, [active]) + + if (active === undefined) return + + return ( +
+ +
+

+ {SURFACE_LABELS[active]} +

+ {active === "cycle" && ( + + )} +
+
+ ) +} + const AdminPanel: React.FC = () => { const { status, surfaces, grantedBy, grants, signersUnknown, addresses } = useAdminAuthority() const { address } = useWallet() + const held = SURFACE_ORDER.filter((surface) => surfaces.has(surface)) return (
@@ -82,14 +116,10 @@ const AdminPanel: React.FC = () => { )} {status === "checking" && ( -
    -
  • - -
  • -
  • - -
  • -
+
+ + +
)} {status === "unreadable" && ( @@ -98,41 +128,18 @@ const AdminPanel: React.FC = () => {

)} - {status === "ready" && surfaces.size === 0 && ( + {status === "ready" && held.length === 0 && (

This address holds no authority on this vault.

)} - {status === "ready" && surfaces.size > 0 && ( -
    - {SURFACE_ORDER.filter((surface) => surfaces.has(surface)).map( - (surface) => ( -
  • -

    - {SURFACE_LABELS[surface]} -

    - - Granted by{" "} - {grantedBy[surface] - .map((grant) => describeCardGrant(grant, address ?? "")) - .join(", ")} - - {surface === "cycle" && ( - - )} -
  • - ), - )} -
+ {status === "ready" && held.length > 0 && ( + )} {status === "ready" && } From b17935bb90788413a5355d63817edef8d8d80d60 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:09:20 -0300 Subject: [PATCH 02/12] fix(app): focus the surface only on a tab change --- app/src/pages/AdminPanel.module.css | 4 +++ app/src/pages/AdminPanel.test.tsx | 52 ++++++++++++++++++++++++----- app/src/pages/AdminPanel.tsx | 9 +++-- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/app/src/pages/AdminPanel.module.css b/app/src/pages/AdminPanel.module.css index 2ae3568..f4ba9e2 100644 --- a/app/src/pages/AdminPanel.module.css +++ b/app/src/pages/AdminPanel.module.css @@ -23,6 +23,7 @@ } .surface { + position: relative; display: flex; flex-direction: column; gap: 14px; @@ -32,8 +33,11 @@ position: absolute; width: 1px; height: 1px; + margin: -1px; overflow: hidden; + white-space: nowrap; clip: rect(0 0 0 0); + clip-path: inset(50%); } .srOnly:focus { diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 2c5ee83..b8230a0 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -51,15 +51,16 @@ const authority = (overrides: Partial): AdminAuthority => ({ ...overrides, }) -const renderAt = (path: string) => - render( - - - } /> - } /> - - , - ) +const routedPanel = (path: string) => ( + + + } /> + } /> + + +) + +const renderAt = (path: string) => render(routedPanel(path)) const currentTab = () => screen @@ -251,6 +252,39 @@ describe("AdminPanel", () => { ) }) + it("leaves focus alone when losing a surface redirects to another", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + const { rerender } = renderAt("/admin/cycle") + + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["compliance"]), + grantedBy: { ...emptyGrantedBy, compliance: [compliance] }, + grants: [compliance], + }), + ) + rerender(routedPanel("/admin/cycle")) + + expect(currentTab()).toBe("Compliance") + expect(document.activeElement).not.toBe( + screen.getByRole("heading", { name: "Compliance" }), + ) + }) + it("names the signature standing of every grant on the identity line", () => { const governance: Grant = { role: "vault governance", diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 13b48f4..ff98284 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -63,11 +63,16 @@ const HeldSurfaces: React.FC<{ useEffect(() => { if (active === undefined) return - if (shownSurface.current !== undefined && shownSurface.current !== active) { + const previous = shownSurface.current + if ( + previous !== undefined && + previous !== active && + held.includes(previous) + ) { headingRef.current?.focus() } shownSurface.current = active - }, [active]) + }, [active, held]) if (active === undefined) return From eefe16436b220fedf65973d93950bd857c90506a Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:39:12 -0300 Subject: [PATCH 03/12] feat(app): split the cycle into overview and actions --- .../components/admin/CycleSection.test.tsx | 32 ++--- app/src/components/admin/CycleSection.tsx | 24 ++-- .../components/admin/CycleSurface.module.css | 49 ++++++- .../components/admin/CycleSurface.test.tsx | 62 +++++--- app/src/components/admin/CycleSurface.tsx | 132 +++++++++++------- app/src/pages/AdminPanel.module.css | 12 -- app/src/pages/AdminPanel.test.tsx | 61 ++++++-- app/src/pages/AdminPanel.tsx | 34 ++--- app/src/pages/cycle.test.ts | 29 +++- app/src/pages/cycle.ts | 26 ++-- 10 files changed, 307 insertions(+), 154 deletions(-) diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 29dffe1..dbb232f 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -240,26 +240,24 @@ describe("CycleSection", () => { ).toBeTruthy() }) - it("offers the treasury the settlement and both reserve actions, under the Reserve group", () => { + it("offers the treasury the settlement and both reserve actions, in the Actions rail", () => { render() expect(screen.queryByRole("button", { name: "Close epoch" })).toBeNull() expect(screen.queryByRole("button", { name: "Attest price" })).toBeNull() expect(button("Fulfill epoch").disabled).toBe(false) - const reserveGroup = screen.getByRole("heading", { - name: "Reserve", - }).parentElement! + const rail = screen.getByRole("complementary", { name: "Actions" }) expect( - within(reserveGroup).getByRole("button", { name: "Deploy to custodian" }), + within(rail).getByRole("button", { name: "Deploy to custodian" }), ).toBeTruthy() expect( - within(reserveGroup).getByRole("button", { name: "Fund the reserve" }), + within(rail).getByRole("button", { name: "Fund the reserve" }), ).toBeTruthy() expect( - within(reserveGroup).getByRole("textbox", { name: "Amount to deploy" }), + within(rail).getByRole("textbox", { name: "Amount to deploy" }), ).toBeTruthy() expect( - within(reserveGroup).getByRole("textbox", { name: "Amount to fund" }), + within(rail).getByRole("textbox", { name: "Amount to fund" }), ).toBeTruthy() }) @@ -476,14 +474,14 @@ describe("CycleSection", () => { expect(button("Fund the reserve").disabled).toBe(false) }) - it("offers the attester a price field under the Price group, with the button held until a price is typed", () => { + it("offers the attester a price field in the Actions rail, with the button held until a price is typed", () => { render() const field = screen.getByRole("textbox", { name: "Share price" }) - const priceGroup = screen.getByRole("heading", { - name: "Price", - }).parentElement! - expect(within(priceGroup).getByRole("textbox")).toBe(field) + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect(within(rail).getByRole("textbox", { name: "Share price" })).toBe( + field, + ) expect(button("Attest price").disabled).toBe(true) const band = screen.getByText("Within the band").closest("li")! expect(within(band).getByText("—")).toBeTruthy() @@ -686,7 +684,7 @@ describe("CycleSection", () => { expect(screen.queryByRole("button", { name: "Finalize round" })).toBeNull() }) - it("offers every cycle role the activation under the Wind-down group once proposed, and follows it in the modal", () => { + it("offers every cycle role the activation in the Actions rail once proposed, and follows it in the modal", () => { const proposed = ready({ windDown: { ...windDown, phase: "proposed", activeAt: 1_700_000_000n }, }) @@ -694,10 +692,8 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const windDownGroup = screen.getByRole("heading", { - name: "Wind-down", - }).parentElement! - const activate = within(windDownGroup).getByRole("button", { + const rail = screen.getByRole("complementary", { name: "Actions" }) + const activate = within(rail).getByRole("button", { name: "Activate wind-down", }) as HTMLButtonElement diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 3bd0f5a..02c0edf 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -24,7 +24,7 @@ import { import TransactionModal, { type TransactionModalProps, } from "../vault/TransactionModal" -import CycleSurface, { CycleActions, type CycleGroup } from "./CycleSurface" +import CycleSurface, { CycleActions } from "./CycleSurface" const useAmountInput = (confirmed: boolean) => { const [input, setInput] = useState("") @@ -34,10 +34,11 @@ const useAmountInput = (confirmed: boolean) => { return [input, setInput] as const } -const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ - grants, - wallet, -}) => { +const CycleSection: React.FC<{ + grants: Grant[] + wallet: string + contracts?: React.ReactNode +}> = ({ grants, wallet, contracts }) => { const { cycle } = useCycleState() const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() @@ -118,15 +119,6 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ fund: fundInput, walletBalance: balance.status === "held" ? balance.amount : null, }) - const withActions = (group: CycleGroup): CycleGroup => { - const own = actions.filter((action) => action.group === group.title) - return own.length === 0 - ? group - : { - ...group, - actions: , - } - } const freshness = cycle.status === "ready" ? cycle.oracle.limits?.freshness : undefined @@ -187,10 +179,12 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ return ( <> } + contracts={contracts} /> {activeModal !== undefined && } diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index 65ee2f9..46b5229 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -1,7 +1,54 @@ -.surface { +.layout { + container-type: inline-size; +} + +.body { + display: grid; + grid-template-columns: minmax(0, 2.2fr) minmax(320px, 1fr); + align-items: start; + gap: 18px 32px; +} + +.main { display: flex; flex-direction: column; gap: 22px; + min-width: 0; +} + +.side { + position: sticky; + top: 24px; + min-width: 0; +} + +@container (max-width: 1080px) { + .body { + grid-template-columns: minmax(0, 1fr); + } + + .side { + position: static; + order: -1; + } +} + +.disclosure { + border-top: 1px solid var(--vault-border-hairline); + padding-top: 11px; +} + +.disclosureTitle { + cursor: pointer; + color: var(--vault-text-body); +} + +.disclosureTitle:hover { + color: var(--vault-ink); +} + +.disclosureBody { + padding-top: 11px; } .group { diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index 25a1e7c..8a6065d 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, within } from "@testing-library/react" +import type React from "react" import { describe, expect, it, vi } from "vitest" import { type CycleAction } from "../../pages/cycle" import CycleSurface, { @@ -30,7 +31,8 @@ const groups: CycleGroup[] = [ { title: "Reserve", tiles: [{ label: "Uncovered", value: "0.00", note: "Covered" }], - rows: [{ label: "Deposit cap", value: "No cap" }], + rows: [{ label: "Liquid reserve", value: "800.00" }], + limits: [{ label: "Deposit cap", value: "No cap" }], addresses: [{ label: "Custodian", address: "CCUSTODIAN1234567890" }], }, ] @@ -77,6 +79,7 @@ const renderSurface = ( unreadable?: boolean epochs?: ListState activity?: ListState + contracts?: React.ReactNode } = {}, ) => render( @@ -85,6 +88,8 @@ const renderSurface = ( unreadable={overrides.unreadable ?? false} epochs={overrides.epochs ?? { status: "loaded", rows: epochRows }} activity={overrides.activity ?? { status: "loaded", rows: activityRows }} + rail={} + contracts={overrides.contracts} />, ) @@ -115,8 +120,8 @@ describe("CycleSurface", () => { expect(within(epochRowsList!).getByText("1h")).toBeTruthy() const row = within(epochRowsList!).getByText("Sealed at").closest("li") expect(within(row!).getByText("Unavailable")).toBeTruthy() - expect(within(reserveRowsList!).getByText("Deposit cap")).toBeTruthy() - expect(within(reserveRowsList!).getByText("No cap")).toBeTruthy() + expect(within(reserveRowsList!).getByText("Liquid reserve")).toBeTruthy() + expect(within(reserveRowsList!).queryByText("Deposit cap")).toBeNull() expect(within(epochRowsList!).queryByText("Open epoch")).toBeNull() }) @@ -250,24 +255,49 @@ describe("CycleSurface", () => { expect(screen.queryByRole("table", { name: "Recent activity" })).toBeNull() }) - it("renders a group's action area after its rows", () => { - renderSurface({ - groups: [ - { ...groups[0]!, actions: }, - ], - }) + it("keeps a group's static limits in a closed Limits disclosure after its rows", () => { + renderSurface() - const group = screen.getByText("Notice period").closest("li")! - .parentElement!.parentElement! - const button = within(group).getByRole("button", { name: "Close epoch" }) + const disclosure = screen.getByText("Limits").closest("details")! + expect(disclosure.open).toBe(false) + expect(within(disclosure).getByText("Deposit cap")).toBeTruthy() + expect(within(disclosure).getByText("No cap")).toBeTruthy() expect( - group.compareDocumentPosition(button) & - Node.DOCUMENT_POSITION_CONTAINED_BY, + screen.getByText("Liquid reserve").compareDocumentPosition(disclosure) & + Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() + }) + + it("adds no Limits disclosure to a group without limits", () => { + renderSurface({ groups: [groups[0]!] }) + + expect(screen.queryByText("Limits")).toBeNull() + }) + + it("acts only from the Actions aside, the overview holding no action", () => { + renderSurface() + + const rail = screen.getByRole("complementary", { name: "Actions" }) expect( - screen.getByText("Notice period").compareDocumentPosition(button) & - Node.DOCUMENT_POSITION_FOLLOWING, + within(rail).getByRole("button", { name: "Close epoch" }), + ).toBeTruthy() + expect(screen.getAllByRole("button")).toHaveLength(1) + }) + + it("closes the overview with the contracts it is given", () => { + renderSurface({ contracts:

Contract addresses

}) + + const contracts = screen.getByText("Contract addresses") + expect( + screen + .getByRole("table", { name: "Recent activity" }) + .compareDocumentPosition(contracts) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() + expect( + within( + screen.getByRole("complementary", { name: "Actions" }), + ).queryByText("Contract addresses"), + ).toBeNull() }) it("heads each group and section with a sentence-case title below the card title", () => { diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index 169a899..7ae66ce 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -6,15 +6,19 @@ import { } from "../../pages/cycle" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" -import { type AddressRow, type FigureGroup } from "../vault/AboutVault" +import { + type AddressRow, + type FigureGroup, + type FigureRow, +} from "../vault/AboutVault" import AddressRows from "../vault/AddressRows" import MetricsStrip, { type Metric } from "../vault/MetricsStrip" import styles from "./CycleSurface.module.css" export type CycleGroup = FigureGroup & { tiles: Metric[] + limits?: FigureRow[] addresses?: AddressRow[] - actions?: React.ReactNode } export type EpochRow = { @@ -44,6 +48,8 @@ type CycleSurfaceProps = { unreadable: boolean epochs: ListState activity: ListState + rail: React.ReactNode + contracts?: React.ReactNode } const EPOCH_COLUMNS = [ @@ -58,34 +64,54 @@ const ACTIVITY_COLUMNS = ["Time", "Event", "Detail"] const figureCell = `${typeStyles.railValue} ${styles.cell}` +const FigureRows: React.FC<{ rows: FigureRow[] }> = ({ rows }) => ( +
    + {rows.map((row) => ( +
  • + + {row.label} + + {row.pending ? ( + + ) : ( + + {row.value ?? "Unavailable"} + + )} +
  • + ))} +
+) + +export const Disclosure: React.FC<{ + title: string + children: React.ReactNode +}> = ({ title, children }) => ( +
+ + {title} + +
{children}
+
+) + const Group: React.FC<{ group: CycleGroup }> = ({ group }) => (

{group.title}

-
    - {group.rows.map((row) => ( -
  • - - {row.label} - - {row.pending ? ( - - ) : ( - - {row.value ?? "Unavailable"} - - )} -
  • - ))} -
+ {group.addresses !== undefined && } - {group.actions} + {group.limits !== undefined && ( + + + + )}
) @@ -291,29 +317,41 @@ const CycleSurface: React.FC = ({ unreadable, epochs, activity, + rail, + contracts, }) => ( -
- {unreadable ? ( -

Could not read the vault's cycle.

- ) : ( - groups.map((group) => ) - )} -
- {epochs.status === "loaded" && } -
-
- {activity.status === "loaded" && } -
+
+
+
+ {unreadable ? ( +

Could not read the vault's cycle.

+ ) : ( + groups.map((group) => ) + )} +
+ {epochs.status === "loaded" && } +
+
+ {activity.status === "loaded" && ( + + )} +
+ {contracts} +
+ +
) diff --git a/app/src/pages/AdminPanel.module.css b/app/src/pages/AdminPanel.module.css index f4ba9e2..1b61b92 100644 --- a/app/src/pages/AdminPanel.module.css +++ b/app/src/pages/AdminPanel.module.css @@ -43,15 +43,3 @@ .srOnly:focus { outline: none; } - -.addresses { - display: flex; - flex-direction: column; - gap: 11px; - border-top: 1px solid var(--vault-border-hairline); - padding-top: 20px; -} - -.addressesTitle { - color: var(--vault-text-muted); -} diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index b8230a0..49eebd6 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react" +import { fireEvent, render, screen, within } from "@testing-library/react" +import type React from "react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { beforeEach, describe, expect, it, vi } from "vitest" import type * as UseAdminAuthority from "../hooks/useAdminAuthority" @@ -18,8 +19,17 @@ vi.mock("../hooks/useAdminAuthority", async (importOriginal) => { vi.mock("../hooks/useWallet", () => ({ useWallet: useWalletMock })) vi.mock("../components/admin/CycleSection", () => ({ - default: ({ grants }: { grants: Grant[] }) => ( -
Cycle surface for {grants.map((grant) => grant.role).join(", ")}
+ default: ({ + grants, + contracts, + }: { + grants: Grant[] + contracts: React.ReactNode + }) => ( +
+

Cycle surface for {grants.map((grant) => grant.role).join(", ")}

+ {contracts} +
), })) @@ -102,26 +112,47 @@ describe("AdminPanel", () => { }) it("tells an address with no role it holds no authority, listing just the shortened address", () => { - useAdminAuthorityMock.mockReturnValue( - authority({ - status: "ready", - addresses: { - oracle: "CORACLE", - shareToken: "CTOKEN", - identityVerifier: "CVERIFIER", - }, - }), - ) + useAdminAuthorityMock.mockReturnValue(authority({ status: "ready" })) renderAt("/admin") expect( screen.getByText("This address holds no authority on this vault."), ).toBeTruthy() - expect(screen.getByText("Addresses")).toBeTruthy() - expect(screen.getByText("Oracle")).toBeTruthy() expect(screen.getByText("GCON...7890")).toBeTruthy() + expect(screen.queryByText("Contracts")).toBeNull() }) + it.each(["/admin/cycle", "/admin/compliance"])( + "keeps the contract addresses in a closed Contracts disclosure on %s", + (path) => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + addresses: { + oracle: "CORACLE", + shareToken: "CTOKEN", + identityVerifier: "CVERIFIER", + }, + }), + ) + renderAt(path) + + const disclosure = screen.getByText("Contracts").closest("details") + expect(disclosure?.open).toBe(false) + expect(within(disclosure!).getByText("Oracle")).toBeTruthy() + expect(within(disclosure!).getByText("Identity verifier")).toBeTruthy() + }, + ) + it("keeps the page-level states free of surface tabs", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "checking" })) renderAt("/admin/cycle") diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index ff98284..c31ec7c 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -2,6 +2,7 @@ import { shortAddress } from "@stellar-scaffold/app-lib" import React, { useEffect, useRef } from "react" import { Navigate, useParams } from "react-router-dom" import CycleSection from "../components/admin/CycleSection" +import { Disclosure } from "../components/admin/CycleSurface" import SurfaceTabs from "../components/admin/SurfaceTabs" import Skeleton from "../components/Skeleton" import AddressRows, { type AddressRow } from "../components/vault/AddressRows" @@ -40,22 +41,12 @@ const describeGrant = (grant: Grant, wallet: string): string => ? grant.role : `${grant.role} (${describeStanding(grant.standing)})` -const AddressesBlock: React.FC<{ addresses: AdminAddresses }> = ({ - addresses, -}) => ( -
- - Addresses - - -
-) - const HeldSurfaces: React.FC<{ held: AdminSurface[] grantedBy: Record wallet: string -}> = ({ held, grantedBy, wallet }) => { + addresses: AdminAddresses +}> = ({ held, grantedBy, wallet, addresses }) => { const { surface } = useParams() const active = held.find((candidate) => candidate === surface) const headingRef = useRef(null) @@ -76,6 +67,12 @@ const HeldSurfaces: React.FC<{ if (active === undefined) return + const contracts = ( + + + + ) + return (
@@ -83,8 +80,14 @@ const HeldSurfaces: React.FC<{

{SURFACE_LABELS[active]}

- {active === "cycle" && ( - + {active === "cycle" ? ( + + ) : ( + contracts )}
@@ -144,10 +147,9 @@ const AdminPanel: React.FC = () => { held={held} grantedBy={grantedBy} wallet={address ?? ""} + addresses={addresses} /> )} - - {status === "ready" && }
) } diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index a0f171f..0656512 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -126,8 +126,12 @@ const ready = (overrides: Partial) => const groupOf = (groups: CycleGroup[], title: string) => groups.find((group) => group.title === title) -const valueOf = (groups: CycleGroup[], title: string, label: string) => - groupOf(groups, title)?.rows.find((row) => row.label === label)?.value +const valueOf = (groups: CycleGroup[], title: string, label: string) => { + const group = groupOf(groups, title) + return [...(group?.rows ?? []), ...(group?.limits ?? [])].find( + (row) => row.label === label, + )?.value +} const tileOf = (groups: CycleGroup[], title: string, label: string) => groupOf(groups, title)?.tiles.find((tile) => tile.label === label) @@ -162,8 +166,20 @@ describe("toCycleRows", () => { ]) expect(groups.map((group) => group.rows.map((row) => row.label))).toEqual([ ["Vault", "Notice period", "Sealed at", "Notice"], + ["Ripcord"], + ["Committed", "Liquid reserve"], + ["Activates at", "Delay", "Supply"], + ]) + }) + + it("keeps the static limits apart from the live rows, in Price and Reserve only", () => { + const groups = toCycleRows(ready({})) + + expect( + groups.map((group) => group.limits?.map((row) => row.label)), + ).toEqual([ + undefined, [ - "Ripcord", "Freshness window", "Cooldown", "Largest rise", @@ -171,8 +187,8 @@ describe("toCycleRows", () => { "Price floor", "Price ceiling", ], - ["Committed", "Liquid reserve", "Deposit cap"], - ["Activates at", "Delay", "Supply"], + ["Deposit cap"], + undefined, ]) }) @@ -183,6 +199,7 @@ describe("toCycleRows", () => { groups.map((group) => [ group.tiles.map((tile) => tile.label), group.rows.map((row) => row.label), + group.limits?.map((row) => row.label), ]) expect(labelsOf(pending)).toEqual(labelsOf(labelled)) @@ -190,7 +207,7 @@ describe("toCycleRows", () => { pending.every( (group) => group.tiles.every((tile) => tile.pending) && - group.rows.every((row) => row.pending), + [...group.rows, ...(group.limits ?? [])].every((row) => row.pending), ), ).toBe(true) }) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 641fecf..28fc079 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -38,7 +38,12 @@ type ReadyState = Extract const ACTIVITY_LIMIT = 10 const NOT_APPLICABLE = "—" -const GROUP_LAYOUT: { title: string; tiles: string[]; rows: string[] }[] = [ +const GROUP_LAYOUT: { + title: string + tiles: string[] + rows: string[] + limits?: string[] +}[] = [ { title: "Epoch", tiles: [ @@ -52,8 +57,8 @@ const GROUP_LAYOUT: { title: string; tiles: string[]; rows: string[] }[] = [ { title: "Price", tiles: ["Share price", "Attested", "Valid until"], - rows: [ - "Ripcord", + rows: ["Ripcord"], + limits: [ "Freshness window", "Cooldown", "Largest rise", @@ -65,7 +70,8 @@ const GROUP_LAYOUT: { title: string; tiles: string[]; rows: string[] }[] = [ { title: "Reserve", tiles: ["Free", "Uncovered", "Net deployed", "Custodian balance"], - rows: ["Committed", "Liquid reserve", "Deposit cap"], + rows: ["Committed", "Liquid reserve"], + limits: ["Deposit cap"], }, { title: "Wind-down", @@ -240,6 +246,8 @@ function priceGroup(oracle: CycleOracle): CycleGroup { ? "Raised" : "Not raised", ), + ], + limits: [ row("Freshness window", limits && formatDuration(limits.freshness)), row("Cooldown", limits && formatDuration(limits.cooldown)), row("Largest rise", limits && formatPercent(limits.maxUpBps)), @@ -287,8 +295,8 @@ function reserveGroup(state: ReadyState): CycleGroup { rows: [ row("Committed", formatAmountValue(reserve.committed)), row("Liquid reserve", formatAmountValue(reserve.liquid)), - row("Deposit cap", depositCapValue(reserve.depositCap)), ], + limits: [row("Deposit cap", depositCapValue(reserve.depositCap))], addresses: [{ label: "Custodian", address: reserve.custodian }], } } @@ -843,10 +851,12 @@ export function toCycleActions( export function toCycleRows(state: CycleState): CycleGroup[] { if (state.status === "unreadable") return [] if (state.status === "checking") { - return GROUP_LAYOUT.map(({ title, tiles, rows }) => ({ + const pending = (label: string) => ({ label, value: null, pending: true }) + return GROUP_LAYOUT.map(({ title, tiles, rows, limits }) => ({ title, - tiles: tiles.map((label) => ({ label, value: null, pending: true })), - rows: rows.map((label) => ({ label, value: null, pending: true })), + tiles: tiles.map(pending), + rows: rows.map(pending), + ...(limits === undefined ? {} : { limits: limits.map(pending) }), })) } From 9af63cafdc5eb52fcc0d8d4aceb1e3e2fb2ae27a Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:39:52 -0300 Subject: [PATCH 04/12] feat(app): collapse cycle actions into a rail --- .../admin/CycleActionRail.module.css | 224 ++++++++++ .../components/admin/CycleActionRail.test.tsx | 392 ++++++++++++++++++ app/src/components/admin/CycleActionRail.tsx | 191 +++++++++ .../components/admin/CycleSection.test.tsx | 169 +++++--- app/src/components/admin/CycleSection.tsx | 12 +- .../components/admin/CycleSurface.module.css | 136 ------ .../components/admin/CycleSurface.test.tsx | 250 +---------- app/src/components/admin/CycleSurface.tsx | 104 ----- app/src/pages/cycle.test.ts | 54 +++ app/src/pages/cycle.ts | 19 + 10 files changed, 999 insertions(+), 552 deletions(-) create mode 100644 app/src/components/admin/CycleActionRail.module.css create mode 100644 app/src/components/admin/CycleActionRail.test.tsx create mode 100644 app/src/components/admin/CycleActionRail.tsx diff --git a/app/src/components/admin/CycleActionRail.module.css b/app/src/components/admin/CycleActionRail.module.css new file mode 100644 index 0000000..3e6cc47 --- /dev/null +++ b/app/src/components/admin/CycleActionRail.module.css @@ -0,0 +1,224 @@ +.panel { + display: flex; + flex-direction: column; + gap: 15px; + padding: 24px; + border: 1px solid var(--vault-ink); + background: var(--vault-surface-card); +} + +.head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 6px 12px; +} + +.heading { + margin: 0; + color: var(--vault-ink); +} + +.count { + color: var(--vault-text-muted); +} + +.skeleton { + height: 1.2em; + width: 60%; +} + +.list { + margin: 0; + padding: 0; + list-style: none; +} + +.item { + border-top: 1px solid var(--vault-border-hairline); +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 12px 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--vault-ink); + text-align: left; + cursor: pointer; +} + +.toggle:hover .toggleLabel { + color: var(--vault-action); +} + +.toggle:focus-visible { + outline: 2px solid var(--vault-action); + outline-offset: 2px; +} + +.marker, +.markerReady { + flex: none; + width: 10px; + height: 10px; + border: 1.5px solid var(--vault-border-inactive); + background: transparent; +} + +.markerReady { + border-color: var(--vault-action); + background: var(--vault-action); +} + +.toggleLabel { + flex: 1; + min-width: 0; + font-weight: 500; +} + +.summary { + flex: none; + color: var(--vault-text-muted); +} + +.body { + display: flex; + flex-direction: column; + gap: 11px; + padding-bottom: 18px; +} + +.body[hidden] { + display: none; +} + +.conditions { + display: flex; + flex-direction: column; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.condition { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.conditionLabel { + color: var(--vault-text-muted); +} + +.met, +.unmet { + text-align: right; + white-space: nowrap; +} + +.unmet { + color: var(--vault-text-muted); +} + +.note { + margin: 0; + color: var(--vault-text-muted); +} + +.outcome { + margin: 0; + color: var(--vault-text-body); +} + +.actionPrimary, +.actionUnavailable { + min-height: 48px; + padding: 13px 18px; + font-family: var(--vault-font-sans); + font-size: 15.5px; + font-weight: 500; + border-radius: 0; +} + +.actionPrimary { + border: 1px solid var(--vault-action); + background: var(--vault-action); + color: var(--vault-surface-card); +} + +.actionPrimary:hover:not(:disabled) { + background: var(--vault-action-ring); + border-color: var(--vault-action); + color: var(--vault-action); +} + +.actionUnavailable { + border: 1px solid var(--vault-border-hairline); + background: var(--vault-surface-inset); + color: var(--vault-text-muted); + cursor: not-allowed; +} + +.field, +.fieldOver { + display: flex; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 8px 8px 8px 16px; + border: 1px solid var(--vault-action); + background: var(--vault-surface-card); +} + +.fieldOver { + border: 2px solid var(--vault-danger); +} + +.fieldInput { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: var(--vault-ink); +} + +.fieldInput::placeholder { + color: var(--vault-text-muted); +} + +.useMax { + flex: none; + min-height: 40px; + padding: 9px 11px; + border: 1px solid var(--vault-border-hairline); + border-radius: 0; + background: var(--vault-surface-card); + color: var(--vault-text-body); + font-family: var(--vault-font-mono); + font-weight: 500; + font-size: 12px; +} + +.useMax:hover { + background: var(--vault-action-ring); + border-color: var(--vault-action); + color: var(--vault-action); +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx new file mode 100644 index 0000000..0c95358 --- /dev/null +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -0,0 +1,392 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { type CycleAction } from "../../pages/cycle" +import CycleActionRail from "./CycleActionRail" + +vi.mock("@stellar-scaffold/app-lib", () => ({ + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, +})) + +const closeAction: CycleAction = { + id: "close-epoch", + group: "Epoch", + label: "Close epoch", + conditions: [ + { label: "Wind-down not active", met: true }, + { label: "Epoch open", met: true }, + ], + outcome: "Seals epoch 5 and opens epoch 6.", + epochId: 5n, + enabled: true, +} + +const fulfillAction: CycleAction = { + id: "fulfill-epoch", + group: "Epoch", + label: "Fulfill epoch", + conditions: [ + { label: "Sealed epoch awaiting a price", met: true }, + { label: "Notice elapsed", met: false }, + ], + outcome: "Prices epoch 4 at 1.0000 and settles its requests.", + epochId: 4n, + enabled: false, +} + +const attestAction: CycleAction = { + id: "attest", + group: "Price", + label: "Attest price", + conditions: [ + { label: "Within the band", met: null }, + { label: "Cooldown elapsed", met: true }, + ], + outcome: null, + field: { + label: "Share price", + placeholder: "0.0000", + value: "", + invalid: false, + }, + price: null, + freshness: null, + enabled: false, +} + +const deployAction: CycleAction = { + id: "deploy", + group: "Reserve", + label: "Deploy to custodian", + conditions: [ + { label: "Custodian set", met: true, detail: "CCUS...7890" }, + { label: "Within the free reserve", met: null }, + ], + outcome: null, + field: { + label: "Amount to deploy", + placeholder: "0.00", + value: "", + invalid: false, + max: "900.0000001", + }, + amount: null, + enabled: false, +} + +const unavailableClose: CycleAction = { + ...closeAction, + enabled: false, + unavailable: "Needs 2 signatures; collecting them is not supported yet.", +} + +const renderRail = ( + actions: CycleAction[], + { + onRun = vi.fn(), + onInput = vi.fn(), + pending = false, + }: { + onRun?: (action: CycleAction) => void + onInput?: (action: CycleAction, value: string) => void + pending?: boolean + } = {}, +) => + render( + , + ) + +const rowToggle = (label: string) => + screen + .getAllByRole("button", { name: label }) + .find((button) => button.hasAttribute("aria-expanded"))! + +const submitButton = (label: string) => + screen + .getAllByRole("button", { name: label }) + .find((button) => !button.hasAttribute("aria-expanded")) as + HTMLButtonElement | undefined + +const expand = (label: string) => { + const toggle = rowToggle(label) + if (toggle.getAttribute("aria-expanded") !== "true") fireEvent.click(toggle) +} + +const expanded = (label: string) => + rowToggle(label).getAttribute("aria-expanded") === "true" + +const summaryOf = (label: string) => + document.getElementById(rowToggle(label).getAttribute("aria-describedby")!) + ?.textContent + +describe("CycleActionRail", () => { + it("heads the rail with how many of its actions are ready", () => { + renderRail([closeAction, fulfillAction, attestAction]) + + expect(screen.getByRole("heading", { name: "Actions" })).toBeTruthy() + expect(screen.getByText("1 of 3 ready")).toBeTruthy() + }) + + it("lists one row per action, in order, summarising its readiness", () => { + renderRail([unavailableClose, fulfillAction, attestAction, deployAction]) + + const toggles = screen + .getAllByRole("button") + .filter((button) => button.hasAttribute("aria-expanded")) + expect( + toggles.map((toggle) => toggle.getAttribute("aria-labelledby")), + ).toEqual([ + "cycle-close-epoch-action-label", + "cycle-fulfill-epoch-action-label", + "cycle-attest-action-label", + "cycle-deploy-action-label", + ]) + expect(summaryOf("Close epoch")).toBe("Unavailable") + expect(summaryOf("Fulfill epoch")).toBe("1 unmet") + expect(summaryOf("Attest price")).toBe("1 unmet") + expect(summaryOf("Deploy to custodian")).toBe("1 unmet") + }) + + it("summarises a ready action as Ready", () => { + renderRail([closeAction]) + + expect(summaryOf("Close epoch")).toBe("Ready") + }) + + it("opens the first ready action and keeps the others collapsed", () => { + renderRail([fulfillAction, closeAction, { ...attestAction, enabled: true }]) + + expect(expanded("Fulfill epoch")).toBe(false) + expect(expanded("Close epoch")).toBe(true) + expect(expanded("Attest price")).toBe(false) + expect(submitButton("Close epoch")).toBeTruthy() + expect(submitButton("Fulfill epoch")).toBeUndefined() + }) + + it("keeps every row collapsed when no action is ready", () => { + renderRail([fulfillAction, attestAction]) + + expect(expanded("Fulfill epoch")).toBe(false) + expect(expanded("Attest price")).toBe(false) + expect(screen.queryByRole("textbox")).toBeNull() + }) + + it("opens one row at a time and collapses the open row on a second click", () => { + renderRail([closeAction, fulfillAction]) + + fireEvent.click(rowToggle("Fulfill epoch")) + + expect(expanded("Fulfill epoch")).toBe(true) + expect(expanded("Close epoch")).toBe(false) + expect(submitButton("Fulfill epoch")).toBeTruthy() + expect(submitButton("Close epoch")).toBeUndefined() + + fireEvent.click(rowToggle("Fulfill epoch")) + + expect(expanded("Fulfill epoch")).toBe(false) + expect(expanded("Close epoch")).toBe(false) + }) + + it("controls the region holding the action's content", () => { + renderRail([closeAction, fulfillAction]) + + const region = document.getElementById( + rowToggle("Fulfill epoch").getAttribute("aria-controls")!, + )! + expect(region.hidden).toBe(true) + + fireEvent.click(rowToggle("Fulfill epoch")) + + expect(region.hidden).toBe(false) + expect( + within(region).getByRole("button", { name: "Fulfill epoch" }), + ).toBeTruthy() + }) + + it("keeps the open row open once its action stops being ready", () => { + const { rerender } = renderRail([closeAction, fulfillAction]) + + rerender( + , + ) + + expect(expanded("Close epoch")).toBe(true) + expect(expanded("Fulfill epoch")).toBe(false) + }) + + it("opens the first ready action once the actions arrive", () => { + const { rerender } = renderRail([], { pending: true }) + + rerender( + , + ) + + expect(expanded("Close epoch")).toBe(true) + }) + + it("shows a loading line and no rows while the actions are being read", () => { + renderRail([], { pending: true }) + + expect(screen.queryByRole("button")).toBeNull() + expect(screen.queryByText(/ready/)).toBeNull() + }) + + it("says so when there is no action to offer", () => { + renderRail([]) + + expect(screen.getByText("No actions available.")).toBeTruthy() + }) + + it("lists each condition as met or not met", () => { + renderRail([fulfillAction]) + expand("Fulfill epoch") + + const list = screen.getByRole("list", { name: "Fulfill epoch conditions" }) + const [awaiting, notice] = within(list).getAllByRole("listitem") + expect( + within(awaiting!).getByText("Sealed epoch awaiting a price"), + ).toBeTruthy() + expect(within(awaiting!).getByText("Met")).toBeTruthy() + expect(within(notice!).getByText("Notice elapsed")).toBeTruthy() + expect(within(notice!).getByText("Not met")).toBeTruthy() + }) + + it("states the resulting state before the button and runs only an enabled action", () => { + const onRun = vi.fn() + renderRail([closeAction, fulfillAction], { onRun }) + + expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() + const close = submitButton("Close epoch")! + expect(close.disabled).toBe(false) + fireEvent.click(close) + expand("Fulfill epoch") + const fulfill = submitButton("Fulfill epoch")! + expect(fulfill.disabled).toBe(true) + fireEvent.click(fulfill) + + expect(onRun).toHaveBeenCalledTimes(1) + expect(onRun).toHaveBeenCalledWith(closeAction) + }) + + it("says in full why an action is unavailable once expanded, keeping its button disabled", () => { + renderRail([unavailableClose]) + expand("Close epoch") + + expect( + screen.getByText( + "Needs 2 signatures; collecting them is not supported yet.", + ), + ).toBeTruthy() + expect(submitButton("Close epoch")?.disabled).toBe(true) + }) + + it("omits the resulting state when there is none to show", () => { + renderRail([{ ...fulfillAction, outcome: null }]) + expand("Fulfill epoch") + + expect(screen.queryByText(/Prices epoch/)).toBeNull() + expect(submitButton("Fulfill epoch")).toBeTruthy() + }) + + it("asks for a figure in a decimal field, labelled for screen readers, and reports each keystroke", () => { + const onInput = vi.fn() + renderRail([attestAction], { onInput }) + expand("Attest price") + + const field = screen.getByRole("textbox", { + name: "Share price", + }) as HTMLInputElement + expect(field.placeholder).toBe("0.0000") + expect(field.inputMode).toBe("decimal") + + fireEvent.change(field, { target: { value: "1.04" } }) + + expect(onInput).toHaveBeenCalledWith(attestAction, "1.04") + }) + + it("renders no field for an action that takes no figure", () => { + renderRail([closeAction]) + + expect(screen.queryByRole("textbox")).toBeNull() + }) + + it("leaves a condition open with a dash while it cannot be judged yet", () => { + renderRail([attestAction]) + expand("Attest price") + + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("—")).toBeTruthy() + expect(within(band).queryByText("Not met")).toBeNull() + }) + + it("shows a condition's detail in place of Not met when it has one", () => { + renderRail([ + { + ...attestAction, + conditions: [ + { label: "Within the band", met: false, detail: "0.5000 – 2.0000" }, + ], + }, + ]) + expand("Attest price") + + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("0.5000 – 2.0000")).toBeTruthy() + expect(within(band).queryByText("Not met")).toBeNull() + }) + + it("shows a condition's detail in place of Met when it has one", () => { + renderRail([deployAction]) + expand("Deploy to custodian") + + const custodian = screen.getByText("Custodian set").closest("li")! + expect(within(custodian).getByText("CCUS...7890")).toBeTruthy() + expect(within(custodian).queryByText("Met")).toBeNull() + }) + + it("offers to fill the field with its max, reporting the exact figure", () => { + const onInput = vi.fn() + renderRail([deployAction], { onInput }) + expand("Deploy to custodian") + + fireEvent.click(screen.getByRole("button", { name: "Use max" })) + + expect(onInput).toHaveBeenCalledWith(deployAction, "900.0000001") + }) + + it("offers no max for a field that has none", () => { + renderRail([attestAction]) + expand("Attest price") + + expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() + }) + + it("states an action's note outside the conditions", () => { + renderRail([ + { ...attestAction, note: "Ripcord raised — attesting does not lift it." }, + ]) + expand("Attest price") + + const list = screen.getByRole("list", { name: "Attest price conditions" }) + expect( + screen.getByText("Ripcord raised — attesting does not lift it."), + ).toBeTruthy() + expect(within(list).queryByText(/Ripcord/)).toBeNull() + }) +}) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx new file mode 100644 index 0000000..b8a2b7e --- /dev/null +++ b/app/src/components/admin/CycleActionRail.tsx @@ -0,0 +1,191 @@ +import React, { useState } from "react" +import { + type ActionField as ActionFieldSpec, + type Condition, + type CycleAction, + type CycleActionId, + type Readiness, + readiness, + readyCount, +} from "../../pages/cycle" +import typeStyles from "../../styles/type.module.css" +import Skeleton from "../Skeleton" +import styles from "./CycleActionRail.module.css" + +type OnInput = (action: CycleAction, value: string) => void + +const conditionValue = (condition: Condition): string => { + if (condition.met === null) return "—" + return condition.detail ?? (condition.met ? "Met" : "Not met") +} + +const summaryOf = (state: Readiness): string => { + if (state.kind === "ready") return "Ready" + if (state.kind === "unavailable") return "Unavailable" + return `${state.count} unmet` +} + +const ActionField: React.FC<{ + action: CycleAction + field: ActionFieldSpec + onInput: OnInput | undefined +}> = ({ action, field, onInput }) => { + const id = `cycle-${action.id}-field` + return ( +
+ + onInput?.(action, event.target.value)} + placeholder={field.placeholder} + inputMode="decimal" + /> + {field.max !== undefined && ( + + )} +
+ ) +} + +const ActionBody: React.FC<{ + action: CycleAction + onRun: (action: CycleAction) => void + onInput: OnInput | undefined +}> = ({ action, onRun, onInput }) => ( + <> + {action.field !== undefined && ( + + )} +
    + {action.conditions.map((condition) => ( +
  • + + {condition.label} + + + {conditionValue(condition)} + +
  • + ))} +
+ {action.note !== undefined && ( +

{action.note}

+ )} + {action.outcome !== null && ( +

+ {action.outcome} +

+ )} + + {action.unavailable !== undefined && ( +

+ {action.unavailable} +

+ )} + +) + +const CycleActionRail: React.FC<{ + actions: CycleAction[] + pending: boolean + onRun: (action: CycleAction) => void + onInput?: OnInput +}> = ({ actions, pending, onRun, onInput }) => { + const [open, setOpen] = useState() + if (open === undefined && actions.length > 0) + setOpen(actions.find((action) => action.enabled)?.id ?? null) + + return ( +
+
+

+ Actions +

+ {actions.length > 0 && ( + + {readyCount(actions)} of {actions.length} ready + + )} +
+ {actions.length === 0 && + (pending ? ( + + ) : ( +

+ No actions available. +

+ ))} + {actions.length > 0 && ( +
    + {actions.map((action) => { + const state = readiness(action) + const expanded = open === action.id + const id = `cycle-${action.id}-action` + return ( +
  • + + +
  • + ) + })} +
+ )} +
+ ) +} + +export default CycleActionRail diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index dbb232f..738bd23 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -187,8 +187,35 @@ const manager = grant("vault manager") const treasury = grant("vault treasury") const attester = grant("oracle attester") -const button = (name: string) => - screen.getByRole("button", { name }) as HTMLButtonElement +const rowToggle = (label: string) => + screen + .getAllByRole("button", { name: label }) + .find((candidate) => candidate.hasAttribute("aria-expanded"))! + +const openRow = (label: string) => { + const toggle = rowToggle(label) + if (toggle.getAttribute("aria-expanded") !== "true") fireEvent.click(toggle) +} + +const button = (name: string) => { + openRow(name) + return screen + .getAllByRole("button", { name }) + .find( + (candidate) => !candidate.hasAttribute("aria-expanded"), + ) as HTMLButtonElement +} + +const FIELD_ROWS: Record = { + "Amount to deploy": "Deploy to custodian", + "Amount to fund": "Fund the reserve", + "Share price": "Attest price", +} + +const fieldOf = (name: string) => { + openRow(FIELD_ROWS[name]!) + return screen.getByRole("textbox", { name }) as HTMLInputElement +} describe("CycleSection", () => { beforeEach(() => { @@ -229,11 +256,11 @@ describe("CycleSection", () => { render() expect(button("Close epoch").disabled).toBe(false) - expect(button("Fulfill epoch").disabled).toBe(false) const closeConditions = screen.getByRole("list", { name: "Close epoch conditions", }) expect(within(closeConditions).getByText("Epoch open")).toBeTruthy() + expect(button("Fulfill epoch").disabled).toBe(false) expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() expect( screen.getByText("Prices epoch 4 at 1.0000 and settles its requests."), @@ -247,18 +274,10 @@ describe("CycleSection", () => { expect(screen.queryByRole("button", { name: "Attest price" })).toBeNull() expect(button("Fulfill epoch").disabled).toBe(false) const rail = screen.getByRole("complementary", { name: "Actions" }) - expect( - within(rail).getByRole("button", { name: "Deploy to custodian" }), - ).toBeTruthy() - expect( - within(rail).getByRole("button", { name: "Fund the reserve" }), - ).toBeTruthy() - expect( - within(rail).getByRole("textbox", { name: "Amount to deploy" }), - ).toBeTruthy() - expect( - within(rail).getByRole("textbox", { name: "Amount to fund" }), - ).toBeTruthy() + expect(rail.contains(fieldOf("Amount to deploy"))).toBe(true) + expect(rail.contains(button("Deploy to custodian"))).toBe(true) + expect(rail.contains(fieldOf("Amount to fund"))).toBe(true) + expect(rail.contains(button("Fund the reserve"))).toBe(true) }) it("offers the manager the funding but not the deployment", () => { @@ -269,7 +288,7 @@ describe("CycleSection", () => { ).toBeNull() expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() expect(button("Fund the reserve").disabled).toBe(true) - expect(screen.getByRole("textbox", { name: "Amount to fund" })).toBeTruthy() + expect(fieldOf("Amount to fund")).toBeTruthy() }) it("holds the deployment until an amount is typed, then enables it and states the resulting reserve", () => { @@ -279,12 +298,9 @@ describe("CycleSection", () => { const within_ = screen.getByText("Within the free reserve").closest("li")! expect(within(within_).getByText("—")).toBeTruthy() - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { - target: { value: "250" }, - }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250" }, + }) expect(button("Deploy to custodian").disabled).toBe(false) expect(within(within_).getByText("Met")).toBeTruthy() @@ -298,11 +314,9 @@ describe("CycleSection", () => { it("fills the deployment with the exact free reserve on Use max", () => { render() - fireEvent.click(button("Use max")) + const field = fieldOf("Amount to deploy") + fireEvent.click(screen.getByRole("button", { name: "Use max" })) - const field = screen.getByRole("textbox", { - name: "Amount to deploy", - }) as HTMLInputElement expect(field.value).toBe("900.00") expect(button("Deploy to custodian").disabled).toBe(false) expect( @@ -317,12 +331,9 @@ describe("CycleSection", () => { , ) - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { - target: { value: "250" }, - }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250" }, + }) fireEvent.click(button("Deploy to custodian")) expect(deployMock.submit).toHaveBeenCalledWith(amount(250n)) @@ -340,10 +351,9 @@ describe("CycleSection", () => { , ) - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { target: { value: "250.1234567" } }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250.1234567" }, + }) fireEvent.click(button("Deploy to custodian")) deployMock.status = { status: "preparing" } rerender() @@ -357,10 +367,7 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const field = () => - screen.getByRole("textbox", { - name: "Amount to deploy", - }) as HTMLInputElement + const field = () => fieldOf("Amount to deploy") fireEvent.change(field(), { target: { value: "250" } }) fireEvent.click(button("Deploy to custodian")) @@ -376,7 +383,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100.1234567" }, }) fireEvent.click(button("Fund the reserve")) @@ -390,7 +397,7 @@ describe("CycleSection", () => { it("judges the funding against the connected wallet's balance", () => { render() - const field = screen.getByRole("textbox", { name: "Amount to fund" }) + const field = fieldOf("Amount to fund") fireEvent.change(field, { target: { value: "1000.0000001" } }) @@ -414,7 +421,7 @@ describe("CycleSection", () => { }) render() - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100" }, }) @@ -428,7 +435,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100" }, }) fireEvent.click(button("Fund the reserve")) @@ -453,12 +460,9 @@ describe("CycleSection", () => { />, ) - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { - target: { value: "250" }, - }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250" }, + }) expect(button("Deploy to custodian").disabled).toBe(true) expect( @@ -467,7 +471,7 @@ describe("CycleSection", () => { ), ).toBeTruthy() - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100" }, }) @@ -477,7 +481,7 @@ describe("CycleSection", () => { it("offers the attester a price field in the Actions rail, with the button held until a price is typed", () => { render() - const field = screen.getByRole("textbox", { name: "Share price" }) + const field = fieldOf("Share price") const rail = screen.getByRole("complementary", { name: "Actions" }) expect(within(rail).getByRole("textbox", { name: "Share price" })).toBe( field, @@ -500,7 +504,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), { + fireEvent.change(fieldOf("Share price"), { target: { value: "1.04" }, }) fireEvent.click(button("Attest price")) @@ -523,7 +527,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), { + fireEvent.change(fieldOf("Share price"), { target: { value: "1.04123456" }, }) fireEvent.click(button("Attest price")) @@ -539,8 +543,7 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const field = () => - screen.getByRole("textbox", { name: "Share price" }) as HTMLInputElement + const field = () => fieldOf("Share price") fireEvent.change(field(), { target: { value: "1.04" } }) fireEvent.click(button("Attest price")) @@ -559,8 +562,7 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const field = () => - screen.getByRole("textbox", { name: "Share price" }) as HTMLInputElement + const field = () => fieldOf("Share price") fireEvent.change(field(), { target: { value: "1.04" } }) fireEvent.click(button("Attest price")) @@ -675,6 +677,52 @@ describe("CycleSection", () => { expect(fulfillEpochMock.reset).toHaveBeenCalledTimes(1) }) + it("lists the role's actions in the rail with their readiness, the first ready one open", () => { + render() + + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect(within(rail).getByText("2 of 3 ready")).toBeTruthy() + expect(rowToggle("Close epoch").getAttribute("aria-expanded")).toBe("true") + expect(rowToggle("Fulfill epoch").getAttribute("aria-expanded")).toBe( + "false", + ) + expect(rowToggle("Fund the reserve").getAttribute("aria-expanded")).toBe( + "false", + ) + }) + + it("keeps the overview free of actions and the static limits behind a disclosure", () => { + render() + + const rail = screen.getByRole("complementary", { name: "Actions" }) + const overviewButtons = screen + .getAllByRole("button") + .filter((candidate) => !rail.contains(candidate)) + expect(overviewButtons).toEqual([]) + const limits = screen + .getAllByText("Limits") + .map((summary) => summary.closest("details")!) + expect(limits.every((disclosure) => !disclosure.open)).toBe(true) + expect(within(limits[0]!).getByText("Freshness window")).toBeTruthy() + expect(within(limits[1]!).getByText("Deposit cap")).toBeTruthy() + }) + + it("keeps the row open after its action confirms", () => { + const { rerender } = render( + , + ) + fireEvent.click(button("Close epoch")) + + closeEpochMock.status = { + status: "confirmed", + sealedEpoch: 5n, + hash: "abc", + } + rerender() + + expect(rowToggle("Close epoch").getAttribute("aria-expanded")).toBe("true") + }) + it("offers no wind-down action while none is proposed", () => { render() @@ -693,10 +741,9 @@ describe("CycleSection", () => { , ) const rail = screen.getByRole("complementary", { name: "Actions" }) - const activate = within(rail).getByRole("button", { - name: "Activate wind-down", - }) as HTMLButtonElement + const activate = button("Activate wind-down") + expect(rail.contains(activate)).toBe(true) expect(activate.disabled).toBe(false) expect( screen.getByText("Stops new requests and opens distribution rounds."), diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 02c0edf..d5324df 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -24,7 +24,8 @@ import { import TransactionModal, { type TransactionModalProps, } from "../vault/TransactionModal" -import CycleSurface, { CycleActions } from "./CycleSurface" +import CycleActionRail from "./CycleActionRail" +import CycleSurface from "./CycleSurface" const useAmountInput = (confirmed: boolean) => { const [input, setInput] = useState("") @@ -183,7 +184,14 @@ const CycleSection: React.FC<{ unreadable={cycle.status === "unreadable"} epochs={toEpochList(history)} activity={toActivityList(cycleEvents)} - rail={} + rail={ + + } contracts={contracts} /> {activeModal !== undefined && } diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index 46b5229..276339f 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -103,82 +103,6 @@ margin: 0; } -.unmet { - color: var(--vault-text-muted); - text-align: right; - white-space: nowrap; -} - -.actionArea { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); - gap: 20px 24px; - margin-top: 9px; -} - -.action { - display: flex; - flex-direction: column; - gap: 11px; - padding: 16px; - border: 1px solid var(--vault-border-hairline); - background: var(--vault-surface-inset); -} - -.conditions { - display: flex; - flex-direction: column; - gap: 7px; - margin: 0; - padding: 0; - list-style: none; -} - -.conditions .row { - padding-bottom: 0; -} - -.outcome { - margin: 0; - color: var(--vault-text-body); -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.actionPrimary, -.actionUnavailable { - flex: 1; - min-height: 48px; - padding: 13px 18px; - font-family: var(--vault-font-sans); - font-size: 15.5px; - font-weight: 500; - border-radius: 0; -} - -.actionPrimary { - border: 1px solid var(--vault-action); - background: var(--vault-action); - color: var(--vault-surface-card); -} - -.actionPrimary:hover:not(:disabled) { - background: var(--vault-action-ring); - border-color: var(--vault-action); - color: var(--vault-action); -} - -.actionUnavailable { - border: 1px solid var(--vault-border-hairline); - background: var(--vault-surface-card); - color: var(--vault-text-muted); - cursor: not-allowed; -} - .rowSkeleton { height: 1em; width: 11ch; @@ -218,63 +142,3 @@ color: var(--vault-text-muted); white-space: normal; } - -.field, -.fieldOver { - display: flex; - align-items: center; - gap: 10px; - min-height: 58px; - padding: 8px 16px; - border: 1px solid var(--vault-action); - background: var(--vault-surface-card); -} - -.fieldOver { - border: 2px solid var(--vault-danger); -} - -.fieldInput { - flex: 1; - min-width: 0; - border: 0; - outline: none; - background: transparent; - color: var(--vault-ink); -} - -.fieldInput::placeholder { - color: var(--vault-text-muted); -} - -.useMax { - flex: none; - min-height: 40px; - padding: 9px 11px; - border: 1px solid var(--vault-border-hairline); - border-radius: 0; - background: var(--vault-surface-card); - color: var(--vault-text-body); - font-family: var(--vault-font-mono); - font-weight: 500; - font-size: 12px; -} - -.useMax:hover { - background: var(--vault-action-ring); - border-color: var(--vault-action); - color: var(--vault-action); -} - -.note { - margin: 0; - color: var(--vault-text-muted); -} - -.srOnly { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); -} diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index 8a6065d..c4b7f2b 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -1,10 +1,8 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" +import { render, screen, within } from "@testing-library/react" import type React from "react" import { describe, expect, it, vi } from "vitest" -import { type CycleAction } from "../../pages/cycle" import CycleSurface, { type ActivityRow, - CycleActions, type CycleGroup, type EpochRow, type ListState, @@ -310,249 +308,3 @@ describe("CycleSurface", () => { ).toEqual(["Epoch", "Reserve", "Epochs", "Recent activity — last 7 days"]) }) }) - -const closeAction: CycleAction = { - id: "close-epoch", - group: "Epoch", - label: "Close epoch", - conditions: [ - { label: "Wind-down not active", met: true }, - { label: "Epoch open", met: true }, - ], - outcome: "Seals epoch 5 and opens epoch 6.", - epochId: 5n, - enabled: true, -} - -const fulfillAction: CycleAction = { - id: "fulfill-epoch", - group: "Epoch", - label: "Fulfill epoch", - conditions: [ - { label: "Sealed epoch awaiting a price", met: true }, - { label: "Notice elapsed", met: false }, - ], - outcome: "Prices epoch 4 at 1.0000 and settles its requests.", - epochId: 4n, - enabled: false, -} - -const attestAction: CycleAction = { - id: "attest", - group: "Price", - label: "Attest price", - conditions: [ - { label: "Within the band", met: null }, - { label: "Cooldown elapsed", met: true }, - ], - outcome: null, - field: { - label: "Share price", - placeholder: "0.0000", - value: "", - invalid: false, - }, - price: null, - freshness: null, - enabled: false, -} - -const deployAction: CycleAction = { - id: "deploy", - group: "Reserve", - label: "Deploy to custodian", - conditions: [ - { label: "Custodian set", met: true, detail: "CCUS...7890" }, - { label: "Within the free reserve", met: null }, - ], - outcome: null, - field: { - label: "Amount to deploy", - placeholder: "0.00", - value: "", - invalid: false, - max: "900.0000001", - }, - amount: null, - enabled: false, -} - -describe("CycleActions", () => { - it("lists each condition as met or not met, in the card's label and figure typography", () => { - render() - - const list = screen.getByRole("list", { name: "Fulfill epoch conditions" }) - const [awaiting, notice] = within(list).getAllByRole("listitem") - expect( - within(awaiting!).getByText("Sealed epoch awaiting a price"), - ).toBeTruthy() - expect(within(awaiting!).getByText("Met")).toBeTruthy() - expect(within(notice!).getByText("Notice elapsed")).toBeTruthy() - expect(within(notice!).getByText("Not met")).toBeTruthy() - }) - - it("states the resulting state before the button and enables the button only when the action is", () => { - const onRun = vi.fn() - render( - , - ) - - expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() - const close = screen.getByRole("button", { name: "Close epoch" }) - const fulfill = screen.getByRole("button", { name: "Fulfill epoch" }) - expect((close as HTMLButtonElement).disabled).toBe(false) - expect((fulfill as HTMLButtonElement).disabled).toBe(true) - - fireEvent.click(close) - fireEvent.click(fulfill) - - expect(onRun).toHaveBeenCalledTimes(1) - expect(onRun).toHaveBeenCalledWith(closeAction) - }) - - it("says why an action is unavailable and keeps its button disabled", () => { - render( - , - ) - - expect( - screen.getByText( - "Needs 2 signatures; collecting them is not supported yet.", - ), - ).toBeTruthy() - expect( - (screen.getByRole("button", { name: "Close epoch" }) as HTMLButtonElement) - .disabled, - ).toBe(true) - }) - - it("omits the resulting state when there is none to show", () => { - render( - , - ) - - expect(screen.queryByText(/Prices epoch/)).toBeNull() - expect(screen.getByRole("button", { name: "Fulfill epoch" })).toBeTruthy() - }) - - it("asks for a figure in a decimal field, labelled for screen readers, and reports each keystroke", () => { - const onInput = vi.fn() - render( - , - ) - - const field = screen.getByRole("textbox", { - name: "Share price", - }) as HTMLInputElement - expect(field.placeholder).toBe("0.0000") - expect(field.inputMode).toBe("decimal") - - fireEvent.change(field, { target: { value: "1.04" } }) - - expect(onInput).toHaveBeenCalledWith(attestAction, "1.04") - }) - - it("renders no field for an action that takes no figure", () => { - render() - - expect(screen.queryByRole("textbox")).toBeNull() - }) - - it("leaves a condition open with a dash while it cannot be judged yet", () => { - render() - - const band = screen.getByText("Within the band").closest("li")! - expect(within(band).getByText("—")).toBeTruthy() - expect(within(band).queryByText("Not met")).toBeNull() - }) - - it("shows a condition's detail in place of Not met when it has one", () => { - render( - , - ) - - const band = screen.getByText("Within the band").closest("li")! - expect(within(band).getByText("0.5000 – 2.0000")).toBeTruthy() - expect(within(band).queryByText("Not met")).toBeNull() - }) - - it("shows a condition's detail in place of Met when it has one", () => { - render() - - const custodian = screen.getByText("Custodian set").closest("li")! - expect(within(custodian).getByText("CCUS...7890")).toBeTruthy() - expect(within(custodian).queryByText("Met")).toBeNull() - }) - - it("offers to fill the field with its max, reporting the exact figure", () => { - const onInput = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole("button", { name: "Use max" })) - - expect(onInput).toHaveBeenCalledWith(deployAction, "900.0000001") - }) - - it("offers no max for a field that has none", () => { - render() - - expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() - }) - - it("states an action's note outside the conditions", () => { - render( - , - ) - - const list = screen.getByRole("list", { name: "Attest price conditions" }) - expect( - screen.getByText("Ripcord raised — attesting does not lift it."), - ).toBeTruthy() - expect(within(list).queryByText(/Ripcord/)).toBeNull() - }) -}) diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index 7ae66ce..ce829db 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -1,9 +1,4 @@ import React from "react" -import { - type ActionField as ActionFieldSpec, - type Condition, - type CycleAction, -} from "../../pages/cycle" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" import { @@ -115,105 +110,6 @@ const Group: React.FC<{ group: CycleGroup }> = ({ group }) => (
) -const conditionValue = (condition: Condition): string => { - if (condition.met === null) return "—" - return condition.detail ?? (condition.met ? "Met" : "Not met") -} - -const ActionField: React.FC<{ - action: CycleAction - field: ActionFieldSpec - onInput: ((action: CycleAction, value: string) => void) | undefined -}> = ({ action, field, onInput }) => { - const id = `cycle-${action.id}-field` - return ( -
- - onInput?.(action, event.target.value)} - placeholder={field.placeholder} - inputMode="decimal" - /> - {field.max !== undefined && ( - - )} -
- ) -} - -export const CycleActions: React.FC<{ - actions: CycleAction[] - onRun: (action: CycleAction) => void - onInput?: (action: CycleAction, value: string) => void -}> = ({ actions, onRun, onInput }) => ( -
- {actions.map((action) => ( -
- {action.field !== undefined && ( - - )} -
    - {action.conditions.map((condition) => ( -
  • - - {condition.label} - - - {conditionValue(condition)} - -
  • - ))} -
- {action.note !== undefined && ( -

- {action.note} -

- )} - {action.outcome !== null && ( -

- {action.outcome} -

- )} -
- -
- {action.unavailable !== undefined && ( -

- {action.unavailable} -

- )} -
- ))} -
-) - const Section: React.FC<{ title: string state: ListState diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 0656512..946d8dc 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -28,6 +28,8 @@ import { type WindDownAction, formatDuration, formatTimestamp, + readiness, + readyCount, toActivityList, toCycleActions, toCycleRows, @@ -1695,3 +1697,55 @@ describe("toCycleActions, winding down", () => { expect(finalize?.enabled).toBe(false) }) }) + +const actionWith = (overrides: Partial): TreasuryAction => ({ + id: "fund", + group: "Reserve", + label: "Fund the reserve", + conditions: [], + outcome: null, + enabled: false, + amount: null, + ...overrides, +}) + +describe("readiness", () => { + it("reads an enabled action as ready", () => { + expect(readiness(actionWith({ enabled: true }))).toEqual({ kind: "ready" }) + }) + + it("counts the conditions not met, an undecided one included", () => { + const action = actionWith({ + conditions: [ + { label: "Amount above zero", met: false }, + { label: "Within the balance", met: null }, + { label: "Wind-down not active", met: true }, + ], + }) + + expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) + }) + + it("reads an action the wallet cannot sign as unavailable, keeping the reason", () => { + const reason = "Needs 2 signatures; collecting them is not supported yet." + const action = actionWith({ + conditions: [{ label: "Amount above zero", met: false }], + unavailable: reason, + }) + + expect(readiness(action)).toEqual({ kind: "unavailable", reason }) + }) +}) + +describe("readyCount", () => { + it("counts the enabled actions", () => { + expect( + readyCount([ + actionWith({ enabled: true }), + actionWith({}), + actionWith({ enabled: true }), + ]), + ).toBe(2) + expect(readyCount([])).toBe(0) + }) +}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 28fc079..f033ecc 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -824,6 +824,25 @@ function toWindDownActions(state: ReadyState): WindDownAction[] { return actions } +export type Readiness = + | { kind: "ready" } + | { kind: "unmet"; count: number } + | { kind: "unavailable"; reason: string } + +export const readiness = (action: CycleAction): Readiness => { + if (action.enabled) return { kind: "ready" } + if (action.unavailable !== undefined) + return { kind: "unavailable", reason: action.unavailable } + return { + kind: "unmet", + count: action.conditions.filter((condition) => condition.met !== true) + .length, + } +} + +export const readyCount = (actions: CycleAction[]): number => + actions.filter((action) => action.enabled).length + export function toCycleActions( state: CycleState, grants: Grant[], From b0440f9521257d61174f31c15bdd814e5a9af1a2 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:57:29 -0300 Subject: [PATCH 05/12] fix(app): keep every held surface mounted --- app/src/pages/AdminPanel.module.css | 4 +++ app/src/pages/AdminPanel.test.tsx | 38 +++++++++++++++++++-- app/src/pages/AdminPanel.tsx | 51 ++++++++++++++++++----------- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/app/src/pages/AdminPanel.module.css b/app/src/pages/AdminPanel.module.css index 1b61b92..36c99c0 100644 --- a/app/src/pages/AdminPanel.module.css +++ b/app/src/pages/AdminPanel.module.css @@ -43,3 +43,7 @@ .srOnly:focus { outline: none; } + +.surface[hidden] { + display: none; +} diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 49eebd6..b00c104 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -28,6 +28,7 @@ vi.mock("../components/admin/CycleSection", () => ({ }) => (

Cycle surface for {grants.map((grant) => grant.role).join(", ")}

+ {contracts}
), @@ -146,7 +147,9 @@ describe("AdminPanel", () => { ) renderAt(path) - const disclosure = screen.getByText("Contracts").closest("details") + const disclosure = within(screen.getByRole("region")) + .getByText("Contracts") + .closest("details") expect(disclosure?.open).toBe(false) expect(within(disclosure!).getByText("Oracle")).toBeTruthy() expect(within(disclosure!).getByText("Identity verifier")).toBeTruthy() @@ -435,10 +438,39 @@ describe("AdminPanel", () => { ) renderAt("/admin/compliance") - expect(screen.queryByText(/Cycle surface/)).toBeNull() + expect(screen.queryByRole("region", { name: "Cycle" })).toBeNull() fireEvent.click(screen.getByRole("link", { name: "Cycle" })) - expect(screen.getAllByText(/Cycle surface/)).toHaveLength(1) + expect(screen.getByRole("region", { name: "Cycle" })).toBeTruthy() + expect(screen.queryByRole("region", { name: "Compliance" })).toBeNull() + }) + + it("keeps work in progress on a surface when switching tabs and back", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + renderAt("/admin/cycle") + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "250" }, + }) + fireEvent.click(screen.getByRole("link", { name: "Compliance" })) + fireEvent.click(screen.getByRole("link", { name: "Cycle" })) + + expect((screen.getByLabelText("Amount") as HTMLInputElement).value).toBe( + "250", + ) }) }) diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index c31ec7c..420ff6b 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -41,6 +41,12 @@ const describeGrant = (grant: Grant, wallet: string): string => ? grant.role : `${grant.role} (${describeStanding(grant.standing)})` +const Contracts: React.FC<{ addresses: AdminAddresses }> = ({ addresses }) => ( + + + +) + const HeldSurfaces: React.FC<{ held: AdminSurface[] grantedBy: Record @@ -67,29 +73,34 @@ const HeldSurfaces: React.FC<{ if (active === undefined) return - const contracts = ( - - - - ) - return (
-
-

- {SURFACE_LABELS[active]} -

- {active === "cycle" ? ( - - ) : ( - contracts - )} -
+ {held.map((surface) => ( + + ))}
) } From c1bf068fd53d2d374d6dbcc6973c59ba5a5e532c Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:57:41 -0300 Subject: [PATCH 06/12] fix(app): show contracts without authority --- app/src/pages/AdminPanel.test.tsx | 2 +- app/src/pages/AdminPanel.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index b00c104..005c8aa 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -120,7 +120,7 @@ describe("AdminPanel", () => { screen.getByText("This address holds no authority on this vault."), ).toBeTruthy() expect(screen.getByText("GCON...7890")).toBeTruthy() - expect(screen.queryByText("Contracts")).toBeNull() + expect(screen.getByText("Contracts").closest("details")?.open).toBe(false) }) it.each(["/admin/cycle", "/admin/compliance"])( diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 420ff6b..823df3d 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -148,9 +148,12 @@ const AdminPanel: React.FC = () => { )} {status === "ready" && held.length === 0 && ( -

- This address holds no authority on this vault. -

+ <> +

+ This address holds no authority on this vault. +

+ + )} {status === "ready" && held.length > 0 && ( From 442af7e3ade01e3f1edd77a935156ddf0b8880b6 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:58:11 -0300 Subject: [PATCH 07/12] fix(app): order the rail first and bound its height --- app/src/components/admin/CycleSurface.module.css | 15 ++++++++++++++- app/src/components/admin/CycleSurface.test.tsx | 11 +++++++++++ app/src/components/admin/CycleSurface.tsx | 6 +++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index 276339f..457b0d4 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -14,12 +14,18 @@ flex-direction: column; gap: 22px; min-width: 0; + grid-column: 1; + grid-row: 1; } .side { position: sticky; top: 24px; + max-height: calc(100dvh - 24px - 24px); + overflow-y: auto; min-width: 0; + grid-column: 2; + grid-row: 1; } @container (max-width: 1080px) { @@ -27,9 +33,16 @@ grid-template-columns: minmax(0, 1fr); } + .main, + .side { + grid-column: 1; + grid-row: auto; + } + .side { position: static; - order: -1; + max-height: none; + overflow-y: visible; } } diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index c4b7f2b..d87fe5e 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -282,6 +282,17 @@ describe("CycleSurface", () => { expect(screen.getAllByRole("button")).toHaveLength(1) }) + it("puts the Actions aside before the overview in reading order", () => { + renderSurface() + + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect( + rail.compareDocumentPosition( + screen.getByRole("heading", { name: "Epoch" }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) + it("closes the overview with the contracts it is given", () => { renderSurface({ contracts:

Contract addresses

}) diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index ce829db..c5a33ea 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -218,6 +218,9 @@ const CycleSurface: React.FC = ({ }) => (
+
{unreadable ? (

Could not read the vault's cycle.

@@ -244,9 +247,6 @@ const CycleSurface: React.FC = ({ {contracts}
-
) From a9ed71cec35818b783775c021ac9840c52f4b27d Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:58:51 -0300 Subject: [PATCH 08/12] fix(app): report an unreadable cycle in the rail --- .../components/admin/CycleActionRail.test.tsx | 49 ++++++++++++------- app/src/components/admin/CycleActionRail.tsx | 7 ++- app/src/components/admin/CycleSection.tsx | 1 + 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index 0c95358..1174d3b 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -86,16 +86,19 @@ const renderRail = ( onRun = vi.fn(), onInput = vi.fn(), pending = false, + unreadable = false, }: { onRun?: (action: CycleAction) => void onInput?: (action: CycleAction, value: string) => void pending?: boolean + unreadable?: boolean } = {}, ) => render( , @@ -120,10 +123,6 @@ const expand = (label: string) => { const expanded = (label: string) => rowToggle(label).getAttribute("aria-expanded") === "true" -const summaryOf = (label: string) => - document.getElementById(rowToggle(label).getAttribute("aria-describedby")!) - ?.textContent - describe("CycleActionRail", () => { it("heads the rail with how many of its actions are ready", () => { renderRail([closeAction, fulfillAction, attestAction]) @@ -135,27 +134,34 @@ describe("CycleActionRail", () => { it("lists one row per action, in order, summarising its readiness", () => { renderRail([unavailableClose, fulfillAction, attestAction, deployAction]) - const toggles = screen - .getAllByRole("button") - .filter((button) => button.hasAttribute("aria-expanded")) expect( - toggles.map((toggle) => toggle.getAttribute("aria-labelledby")), + screen + .getAllByRole("button") + .filter((button) => button.hasAttribute("aria-expanded")) + .map((toggle) => toggle.textContent), ).toEqual([ - "cycle-close-epoch-action-label", - "cycle-fulfill-epoch-action-label", - "cycle-attest-action-label", - "cycle-deploy-action-label", + "Close epochUnavailable", + "Fulfill epoch1 unmet", + "Attest price1 unmet", + "Deploy to custodian1 unmet", ]) - expect(summaryOf("Close epoch")).toBe("Unavailable") - expect(summaryOf("Fulfill epoch")).toBe("1 unmet") - expect(summaryOf("Attest price")).toBe("1 unmet") - expect(summaryOf("Deploy to custodian")).toBe("1 unmet") + for (const [name, description] of [ + ["Close epoch", "Unavailable"], + ["Fulfill epoch", "1 unmet"], + ["Attest price", "1 unmet"], + ["Deploy to custodian", "1 unmet"], + ] as const) + expect( + screen.getAllByRole("button", { name, description })[0], + ).toBeTruthy() }) it("summarises a ready action as Ready", () => { renderRail([closeAction]) - expect(summaryOf("Close epoch")).toBe("Ready") + expect( + screen.getByRole("button", { name: "Close epoch", description: "Ready" }), + ).toBeTruthy() }) it("opens the first ready action and keeps the others collapsed", () => { @@ -218,6 +224,7 @@ describe("CycleActionRail", () => { { ...fulfillAction, enabled: true }, ]} pending={false} + unreadable={false} onRun={vi.fn()} />, ) @@ -233,6 +240,7 @@ describe("CycleActionRail", () => { , ) @@ -253,6 +261,13 @@ describe("CycleActionRail", () => { expect(screen.getByText("No actions available.")).toBeTruthy() }) + it("says the cycle state could not be read, not that no action exists", () => { + renderRail([], { unreadable: true }) + + expect(screen.getByText("Could not read the cycle state.")).toBeTruthy() + expect(screen.queryByText("No actions available.")).toBeNull() + }) + it("lists each condition as met or not met", () => { renderRail([fulfillAction]) expand("Fulfill epoch") diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index b8a2b7e..8093336 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -111,9 +111,10 @@ const ActionBody: React.FC<{ const CycleActionRail: React.FC<{ actions: CycleAction[] pending: boolean + unreadable: boolean onRun: (action: CycleAction) => void onInput?: OnInput -}> = ({ actions, pending, onRun, onInput }) => { +}> = ({ actions, pending, unreadable, onRun, onInput }) => { const [open, setOpen] = useState() if (open === undefined && actions.length > 0) setOpen(actions.find((action) => action.enabled)?.id ?? null) @@ -135,7 +136,9 @@ const CycleActionRail: React.FC<{ ) : (

- No actions available. + {unreadable + ? "Could not read the cycle state." + : "No actions available."}

))} {actions.length > 0 && ( diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index d5324df..7b65de7 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -188,6 +188,7 @@ const CycleSection: React.FC<{ From 0a428c1d1ad2d0e05190c8c0d73fb09ff4495936 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 11:59:26 -0300 Subject: [PATCH 09/12] fix(app): move Disclosure to its own module --- .../components/admin/CycleSurface.module.css | 18 ------------------ app/src/components/admin/CycleSurface.tsx | 13 +------------ app/src/components/admin/Disclosure.module.css | 17 +++++++++++++++++ app/src/components/admin/Disclosure.tsx | 17 +++++++++++++++++ app/src/pages/AdminPanel.tsx | 2 +- 5 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 app/src/components/admin/Disclosure.module.css create mode 100644 app/src/components/admin/Disclosure.tsx diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index 457b0d4..ac8a496 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -46,24 +46,6 @@ } } -.disclosure { - border-top: 1px solid var(--vault-border-hairline); - padding-top: 11px; -} - -.disclosureTitle { - cursor: pointer; - color: var(--vault-text-body); -} - -.disclosureTitle:hover { - color: var(--vault-ink); -} - -.disclosureBody { - padding-top: 11px; -} - .group { display: flex; flex-direction: column; diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index c5a33ea..b7bd6f9 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -9,6 +9,7 @@ import { import AddressRows from "../vault/AddressRows" import MetricsStrip, { type Metric } from "../vault/MetricsStrip" import styles from "./CycleSurface.module.css" +import Disclosure from "./Disclosure" export type CycleGroup = FigureGroup & { tiles: Metric[] @@ -84,18 +85,6 @@ const FigureRows: React.FC<{ rows: FigureRow[] }> = ({ rows }) => ( ) -export const Disclosure: React.FC<{ - title: string - children: React.ReactNode -}> = ({ title, children }) => ( -
- - {title} - -
{children}
-
-) - const Group: React.FC<{ group: CycleGroup }> = ({ group }) => (

{group.title}

diff --git a/app/src/components/admin/Disclosure.module.css b/app/src/components/admin/Disclosure.module.css new file mode 100644 index 0000000..9091adc --- /dev/null +++ b/app/src/components/admin/Disclosure.module.css @@ -0,0 +1,17 @@ +.disclosure { + border-top: 1px solid var(--vault-border-hairline); + padding-top: 11px; +} + +.title { + cursor: pointer; + color: var(--vault-text-body); +} + +.title:hover { + color: var(--vault-ink); +} + +.body { + padding-top: 11px; +} diff --git a/app/src/components/admin/Disclosure.tsx b/app/src/components/admin/Disclosure.tsx new file mode 100644 index 0000000..833e1b8 --- /dev/null +++ b/app/src/components/admin/Disclosure.tsx @@ -0,0 +1,17 @@ +import type React from "react" +import typeStyles from "../../styles/type.module.css" +import styles from "./Disclosure.module.css" + +const Disclosure: React.FC<{ + title: string + children: React.ReactNode +}> = ({ title, children }) => ( +
+ + {title} + +
{children}
+
+) + +export default Disclosure diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 823df3d..6783921 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -2,7 +2,7 @@ import { shortAddress } from "@stellar-scaffold/app-lib" import React, { useEffect, useRef } from "react" import { Navigate, useParams } from "react-router-dom" import CycleSection from "../components/admin/CycleSection" -import { Disclosure } from "../components/admin/CycleSurface" +import Disclosure from "../components/admin/Disclosure" import SurfaceTabs from "../components/admin/SurfaceTabs" import Skeleton from "../components/Skeleton" import AddressRows, { type AddressRow } from "../components/vault/AddressRows" From 12cd1c82080d2a25e559d8c370d8ccfe87985987 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 12:04:56 -0300 Subject: [PATCH 10/12] fix(app): pause cycle polling on hidden tab --- app/src/components/admin/CycleSection.tsx | 5 +++-- app/src/hooks/useCycleState.test.ts | 22 ++++++++++++++++++++++ app/src/hooks/useCycleState.ts | 6 ++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 7b65de7..4d7101f 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -39,8 +39,9 @@ const CycleSection: React.FC<{ grants: Grant[] wallet: string contracts?: React.ReactNode -}> = ({ grants, wallet, contracts }) => { - const { cycle } = useCycleState() + active?: boolean +}> = ({ grants, wallet, contracts, active = true }) => { + const { cycle } = useCycleState({ active }) const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() const { balance } = useDepositBalance() diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index 84336de..48de2a9 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -363,6 +363,28 @@ describe("useCycleState", () => { } }) + it("stops refetching the cycle reads while inactive and resumes when active again", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + let active = false + const { result, rerender } = renderWithWallet(() => + useCycleState({ active }), + ) + await waitFor(() => expect(result.current.cycle.status).toBe("ready")) + const before = oracleMock.state.mock.calls.length + + await vi.advanceTimersByTimeAsync(31_000) + expect(oracleMock.state.mock.calls.length).toBe(before) + + active = true + rerender() + await vi.advanceTimersByTimeAsync(16_000) + expect(oracleMock.state.mock.calls.length).toBeGreaterThan(before) + } finally { + vi.useRealTimers() + } + }) + it("reads no balance when no custodian is set", async () => { vaultMock.custodian.mockResolvedValue({ result: null }) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index b88c2b7..677d51a 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -262,7 +262,9 @@ async function fetchCustodianBalance( return toAmount(balance) } -export function useCycleState(): { cycle: CycleState } { +export function useCycleState({ active = true }: { active?: boolean } = {}): { + cycle: CycleState +} { const { history } = useEpochHistory() const { figures, isPending: figuresPending } = useVaultFigures() const { pause } = useVaultPaused() @@ -271,7 +273,7 @@ export function useCycleState(): { cycle: CycleState } { queryKey: cycleReadsKey, queryFn: fetchCycleReads, staleTime: STALE_MS, - refetchInterval: REFRESH_MS, + refetchInterval: active ? REFRESH_MS : false, }) const custodian = authorities?.custodian ?? null const { data: custodianBalance, isPending: balancePending } = useQuery({ From 79cda312bdd8d65ea0a54bdedc402c5f85043f82 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 12:04:56 -0300 Subject: [PATCH 11/12] fix(app): unmount flow modal on hidden tab --- .../components/admin/CycleSection.test.tsx | 27 ++++++++++++++ app/src/components/admin/CycleSection.tsx | 4 ++- app/src/pages/AdminPanel.test.tsx | 36 +++++++++++++++++-- app/src/pages/AdminPanel.tsx | 1 + 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 738bd23..1621a09 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -346,6 +346,33 @@ describe("CycleSection", () => { ).toBeTruthy() }) + it("shows the flow modal only while the surface is active, and again on return", () => { + deployMock.status = { status: "preparing" } + const { rerender } = render( + , + ) + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + + rerender( + , + ) + expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) + expect(deployMock.reset).not.toHaveBeenCalled() + + rerender() + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + }) + + it("pauses the cycle polling while the surface is inactive", () => { + const { rerender } = render( + , + ) + expect(useCycleStateMock).toHaveBeenLastCalledWith({ active: false }) + + rerender() + expect(useCycleStateMock).toHaveBeenLastCalledWith({ active: true }) + }) + it("labels the deployment modal with the exact amount that is signed", () => { const { rerender } = render( , diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 4d7101f..a8e93ea 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -196,7 +196,9 @@ const CycleSection: React.FC<{ } contracts={contracts} /> - {activeModal !== undefined && } + {active && activeModal !== undefined && ( + + )} ) } diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 005c8aa..0ecd7a6 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react" import type React from "react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { beforeEach, describe, expect, it, vi } from "vitest" @@ -22,11 +28,13 @@ vi.mock("../components/admin/CycleSection", () => ({ default: ({ grants, contracts, + active, }: { grants: Grant[] contracts: React.ReactNode + active: boolean }) => ( -
+

Cycle surface for {grants.map((grant) => grant.role).join(", ")}

{contracts} @@ -319,6 +327,30 @@ describe("AdminPanel", () => { ) }) + it("marks the cycle surface inactive while another tab is shown", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + + renderAt("/admin/cycle") + expect(screen.getByTestId("cycle-surface").dataset.active).toBe("true") + cleanup() + + renderAt("/admin/compliance") + expect(screen.getByTestId("cycle-surface").dataset.active).toBe("false") + }) + it("names the signature standing of every grant on the identity line", () => { const governance: Grant = { role: "vault governance", diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 6783921..e52dad2 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -94,6 +94,7 @@ const HeldSurfaces: React.FC<{ } /> ) : ( From 0ae61924920a01e6885ecb84dbf8f092273ac7e4 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 15:05:42 -0300 Subject: [PATCH 12/12] style(app): use toHaveLength in the cycle state test --- app/src/hooks/useCycleState.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index 48de2a9..8490bed 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -374,7 +374,7 @@ describe("useCycleState", () => { const before = oracleMock.state.mock.calls.length await vi.advanceTimersByTimeAsync(31_000) - expect(oracleMock.state.mock.calls.length).toBe(before) + expect(oracleMock.state.mock.calls).toHaveLength(before) active = true rerender()