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/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..1174d3b
--- /dev/null
+++ b/app/src/components/admin/CycleActionRail.test.tsx
@@ -0,0 +1,407 @@
+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,
+ unreadable = false,
+ }: {
+ onRun?: (action: CycleAction) => void
+ onInput?: (action: CycleAction, value: string) => void
+ pending?: boolean
+ unreadable?: 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"
+
+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])
+
+ expect(
+ screen
+ .getAllByRole("button")
+ .filter((button) => button.hasAttribute("aria-expanded"))
+ .map((toggle) => toggle.textContent),
+ ).toEqual([
+ "Close epochUnavailable",
+ "Fulfill epoch1 unmet",
+ "Attest price1 unmet",
+ "Deploy to custodian1 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(
+ screen.getByRole("button", { name: "Close epoch", description: "Ready" }),
+ ).toBeTruthy()
+ })
+
+ 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("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")
+
+ 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..8093336
--- /dev/null
+++ b/app/src/components/admin/CycleActionRail.tsx
@@ -0,0 +1,194 @@
+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
+ unreadable: boolean
+ onRun: (action: CycleAction) => void
+ onInput?: OnInput
+}> = ({ actions, pending, unreadable, 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 ? (
+
+ ) : (
+
+ {unreadable
+ ? "Could not read the cycle state."
+ : "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 29dffe1..1621a09 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,38 +256,28 @@ 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."),
).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!
- expect(
- within(reserveGroup).getByRole("button", { name: "Deploy to custodian" }),
- ).toBeTruthy()
- expect(
- within(reserveGroup).getByRole("button", { name: "Fund the reserve" }),
- ).toBeTruthy()
- expect(
- within(reserveGroup).getByRole("textbox", { name: "Amount to deploy" }),
- ).toBeTruthy()
- expect(
- within(reserveGroup).getByRole("textbox", { name: "Amount to fund" }),
- ).toBeTruthy()
+ const rail = screen.getByRole("complementary", { name: "Actions" })
+ 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", () => {
@@ -271,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", () => {
@@ -281,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()
@@ -300,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(
@@ -319,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))
@@ -337,15 +346,41 @@ 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(
,
)
- 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()
@@ -359,10 +394,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"))
@@ -378,7 +410,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"))
@@ -392,7 +424,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" } })
@@ -416,7 +448,7 @@ describe("CycleSection", () => {
})
render()
- fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), {
+ fireEvent.change(fieldOf("Amount to fund"), {
target: { value: "100" },
})
@@ -430,7 +462,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"))
@@ -455,12 +487,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(
@@ -469,21 +498,21 @@ describe("CycleSection", () => {
),
).toBeTruthy()
- fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), {
+ fireEvent.change(fieldOf("Amount to fund"), {
target: { value: "100" },
})
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 field = fieldOf("Share price")
+ 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()
@@ -502,7 +531,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"))
@@ -525,7 +554,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"))
@@ -541,8 +570,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"))
@@ -561,8 +589,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"))
@@ -677,6 +704,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()
@@ -686,7 +759,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,13 +767,10 @@ describe("CycleSection", () => {
const { rerender } = render(
,
)
- const windDownGroup = screen.getByRole("heading", {
- name: "Wind-down",
- }).parentElement!
- const activate = within(windDownGroup).getByRole("button", {
- name: "Activate wind-down",
- }) as HTMLButtonElement
+ const rail = screen.getByRole("complementary", { name: "Actions" })
+ 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 3bd0f5a..a8e93ea 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, type CycleGroup } from "./CycleSurface"
+import CycleActionRail from "./CycleActionRail"
+import CycleSurface from "./CycleSurface"
const useAmountInput = (confirmed: boolean) => {
const [input, setInput] = useState("")
@@ -34,11 +35,13 @@ const useAmountInput = (confirmed: boolean) => {
return [input, setInput] as const
}
-const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({
- grants,
- wallet,
-}) => {
- const { cycle } = useCycleState()
+const CycleSection: React.FC<{
+ grants: Grant[]
+ wallet: string
+ contracts?: React.ReactNode
+ active?: boolean
+}> = ({ grants, wallet, contracts, active = true }) => {
+ const { cycle } = useCycleState({ active })
const { history } = useEpochHistory()
const { cycleEvents } = useCycleEvents()
const { balance } = useDepositBalance()
@@ -118,15 +121,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,12 +181,24 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({
return (
<>
+ }
+ contracts={contracts}
/>
- {activeModal !== undefined && }
+ {active && activeModal !== undefined && (
+
+ )}
>
)
}
diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css
index 65ee2f9..ac8a496 100644
--- a/app/src/components/admin/CycleSurface.module.css
+++ b/app/src/components/admin/CycleSurface.module.css
@@ -1,7 +1,49 @@
-.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;
+ 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) {
+ .body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .main,
+ .side {
+ grid-column: 1;
+ grid-row: auto;
+ }
+
+ .side {
+ position: static;
+ max-height: none;
+ overflow-y: visible;
+ }
}
.group {
@@ -56,82 +98,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;
@@ -171,63 +137,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 25a1e7c..d87fe5e 100644
--- a/app/src/components/admin/CycleSurface.test.tsx
+++ b/app/src/components/admin/CycleSurface.test.tsx
@@ -1,9 +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,
@@ -30,7 +29,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 +77,7 @@ const renderSurface = (
unreadable?: boolean
epochs?: ListState
activity?: ListState
+ contracts?: React.ReactNode
} = {},
) =>
render(
@@ -85,6 +86,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 +118,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,279 +253,69 @@ 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" })
- expect(
- group.compareDocumentPosition(button) &
- Node.DOCUMENT_POSITION_CONTAINED_BY,
- ).toBeTruthy()
+ 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(
- screen.getByText("Notice period").compareDocumentPosition(button) &
+ screen.getByText("Liquid reserve").compareDocumentPosition(disclosure) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
- it("heads each group and section with a sentence-case title below the card title", () => {
- renderSurface()
+ it("adds no Limits disclosure to a group without limits", () => {
+ renderSurface({ groups: [groups[0]!] })
- expect(
- screen
- .getAllByRole("heading", { level: 3 })
- .map((heading) => heading.textContent),
- ).toEqual(["Epoch", "Reserve", "Epochs", "Recent activity — last 7 days"])
+ expect(screen.queryByText("Limits")).toBeNull()
})
-})
-
-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()
+ it("acts only from the Actions aside, the overview holding no action", () => {
+ renderSurface()
- const list = screen.getByRole("list", { name: "Fulfill epoch conditions" })
- const [awaiting, notice] = within(list).getAllByRole("listitem")
+ const rail = screen.getByRole("complementary", { name: "Actions" })
expect(
- within(awaiting!).getByText("Sealed epoch awaiting a price"),
+ within(rail).getByRole("button", { name: "Close epoch" }),
).toBeTruthy()
- expect(within(awaiting!).getByText("Met")).toBeTruthy()
- expect(within(notice!).getByText("Notice elapsed")).toBeTruthy()
- expect(within(notice!).getByText("Not met")).toBeTruthy()
+ expect(screen.getAllByRole("button")).toHaveLength(1)
})
- 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)
+ it("puts the Actions aside before the overview in reading order", () => {
+ renderSurface()
- expect(onRun).toHaveBeenCalledTimes(1)
- expect(onRun).toHaveBeenCalledWith(closeAction)
+ const rail = screen.getByRole("complementary", { name: "Actions" })
+ expect(
+ rail.compareDocumentPosition(
+ screen.getByRole("heading", { name: "Epoch" }),
+ ) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy()
})
- it("says why an action is unavailable and keeps its button disabled", () => {
- render(
- ,
- )
+ it("closes the overview with the contracts it is given", () => {
+ renderSurface({ contracts: Contract addresses
})
+ const contracts = screen.getByText("Contract addresses")
expect(
- screen.getByText(
- "Needs 2 signatures; collecting them is not supported yet.",
- ),
+ screen
+ .getByRole("table", { name: "Recent activity" })
+ .compareDocumentPosition(contracts) & Node.DOCUMENT_POSITION_FOLLOWING,
).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()
+ within(
+ screen.getByRole("complementary", { name: "Actions" }),
+ ).queryByText("Contract addresses"),
+ ).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("heads each group and section with a sentence-case title below the card title", () => {
+ renderSurface()
- 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()
+ screen
+ .getAllByRole("heading", { level: 3 })
+ .map((heading) => heading.textContent),
+ ).toEqual(["Epoch", "Reserve", "Epochs", "Recent activity — last 7 days"])
})
})
diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx
index 169a899..b7bd6f9 100644
--- a/app/src/components/admin/CycleSurface.tsx
+++ b/app/src/components/admin/CycleSurface.tsx
@@ -1,20 +1,20 @@
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 { 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"
+import Disclosure from "./Disclosure"
export type CycleGroup = FigureGroup & {
tiles: Metric[]
+ limits?: FigureRow[]
addresses?: AddressRow[]
- actions?: React.ReactNode
}
export type EpochRow = {
@@ -44,6 +44,8 @@ type CycleSurfaceProps = {
unreadable: boolean
epochs: ListState
activity: ListState
+ rail: React.ReactNode
+ contracts?: React.ReactNode
}
const EPOCH_COLUMNS = [
@@ -58,133 +60,42 @@ 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"}
+
+ )}
+
+ ))}
+
+)
+
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}
-
-)
-
-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}
-
- )}
-
- ))}
+ {group.limits !== undefined && (
+
+
+
+ )}
)
@@ -291,29 +202,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/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/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/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts
index 84336de..8490bed 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).toHaveLength(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({
diff --git a/app/src/pages/AdminPanel.module.css b/app/src/pages/AdminPanel.module.css
index 1354f08..36c99c0 100644
--- a/app/src/pages/AdminPanel.module.css
+++ b/app/src/pages/AdminPanel.module.css
@@ -5,49 +5,45 @@
padding: 32px 0;
}
-.cards {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 14px;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.card {
+.pending {
display: flex;
flex-direction: column;
- gap: 8px;
- padding: 20px;
- border: 1px solid var(--vault-ink);
- background: var(--vault-surface-card);
+ gap: 14px;
}
-.cardWide {
- grid-column: 1 / -1;
+.pendingLine {
+ height: 1.2em;
+ width: 60%;
}
-.cardHeading {
- color: var(--vault-ink);
+.surfaces {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
}
-.cardNote {
- color: var(--vault-text-muted);
+.surface {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
}
-.cardSkeleton {
- height: 1.2em;
- width: 60%;
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ white-space: nowrap;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
}
-.addresses {
- display: flex;
- flex-direction: column;
- gap: 11px;
- border-top: 1px solid var(--vault-border-hairline);
- padding-top: 20px;
+.srOnly:focus {
+ outline: none;
}
-.addressesTitle {
- color: var(--vault-text-muted);
+.surface[hidden] {
+ display: none;
}
diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx
index ad3ef9b..0ecd7a6 100644
--- a/app/src/pages/AdminPanel.test.tsx
+++ b/app/src/pages/AdminPanel.test.tsx
@@ -1,4 +1,12 @@
-import { 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"
import type * as UseAdminAuthority from "../hooks/useAdminAuthority"
import { type AdminAuthority, type Grant } from "../hooks/useAdminAuthority"
@@ -17,8 +25,20 @@ 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,
+ active,
+ }: {
+ grants: Grant[]
+ contracts: React.ReactNode
+ active: boolean
+ }) => (
+
+
Cycle surface for {grants.map((grant) => grant.role).join(", ")}
+
+ {contracts}
+
),
}))
@@ -50,14 +70,30 @@ const authority = (overrides: Partial
): AdminAuthority => ({
...overrides,
})
+const routedPanel = (path: string) => (
+
+
+ } />
+ } />
+
+
+)
+
+const renderAt = (path: string) => render(routedPanel(path))
+
+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 +104,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 +112,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."),
@@ -85,27 +121,58 @@ 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",
- },
- }),
- )
- render()
+ 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.getByText("Contracts").closest("details")?.open).toBe(false)
})
- it("renders a card per held surface, in Cycle · Compliance · Emergency · Configuration · Governance order", () => {
+ 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 = 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()
+ },
+ )
+
+ 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 +190,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 +209,149 @@ 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("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("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",
authority: "GGOVERNANCEMULTISIG",
@@ -173,27 +380,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 +400,7 @@ describe("AdminPanel", () => {
grants: [manager, treasury],
}),
)
- render()
+ renderAt("/admin/cycle")
expect(
screen.getByText("Cycle surface for vault manager, vault treasury"),
@@ -221,9 +417,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 +437,7 @@ describe("AdminPanel", () => {
grants: [governance],
}),
)
- render()
+ renderAt("/admin")
expect(
screen.getByText("GCON...7890 · vault governance (1 of 2 signatures)"),
@@ -253,12 +448,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 +468,41 @@ 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.queryByRole("region", { name: "Cycle" })).toBeNull()
+
+ fireEvent.click(screen.getByRole("link", { name: "Cycle" }))
+
+ expect(screen.getByRole("region", { name: "Cycle" })).toBeTruthy()
+ expect(screen.queryByRole("region", { name: "Compliance" })).toBeNull()
})
- it("shows no cycle surface to a wallet with no Cycle grant", () => {
+ 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(["compliance"]),
- grantedBy: { ...emptyGrantedBy, compliance: [compliance] },
- grants: [compliance],
+ surfaces: new Set(["cycle", "compliance"]),
+ grantedBy: {
+ ...emptyGrantedBy,
+ cycle: [manager],
+ compliance: [compliance],
+ },
+ grants: [manager, compliance],
}),
)
- render()
+ renderAt("/admin/cycle")
- expect(screen.queryByText("Cycle surface")).toBeNull()
+ 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 f1c558a..e52dad2 100644
--- a/app/src/pages/AdminPanel.tsx
+++ b/app/src/pages/AdminPanel.tsx
@@ -1,11 +1,15 @@
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 Disclosure from "../components/admin/Disclosure"
+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,26 +41,76 @@ 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,
-}) => (
-
-
- Addresses
-
+const Contracts: React.FC<{ addresses: AdminAddresses }> = ({ addresses }) => (
+
-
+
)
+const HeldSurfaces: React.FC<{
+ held: AdminSurface[]
+ grantedBy: Record
+ wallet: string
+ addresses: AdminAddresses
+}> = ({ held, grantedBy, wallet, addresses }) => {
+ const { surface } = useParams()
+ const active = held.find((candidate) => candidate === surface)
+ const headingRef = useRef(null)
+ const shownSurface = useRef(undefined)
+
+ useEffect(() => {
+ if (active === undefined) return
+ const previous = shownSurface.current
+ if (
+ previous !== undefined &&
+ previous !== active &&
+ held.includes(previous)
+ ) {
+ headingRef.current?.focus()
+ }
+ shownSurface.current = active
+ }, [active, held])
+
+ if (active === undefined) return
+
+ return (
+
+
+ {held.map((surface) => (
+
+
+ {SURFACE_LABELS[surface]}
+
+ {surface === "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 +136,10 @@ const AdminPanel: React.FC = () => {
)}
{status === "checking" && (
-
+
+
+
+
)}
{status === "unreadable" && (
@@ -98,44 +148,23 @@ const AdminPanel: React.FC = () => {
)}
- {status === "ready" && surfaces.size === 0 && (
-
- This address holds no authority on this vault.
-
+ {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" &&
}
)
}
diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts
index a0f171f..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,
@@ -126,8 +128,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 +168,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 +189,8 @@ describe("toCycleRows", () => {
"Price floor",
"Price ceiling",
],
- ["Committed", "Liquid reserve", "Deposit cap"],
- ["Activates at", "Delay", "Supply"],
+ ["Deposit cap"],
+ undefined,
])
})
@@ -183,6 +201,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 +209,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)
})
@@ -1678,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 641fecf..f033ecc 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 }],
}
}
@@ -816,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[],
@@ -843,10 +870,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) }),
}))
}