Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 0 additions & 37 deletions frontend/src/components/ApprovalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,40 +40,3 @@ export const ApprovalBar = React.memo(function ApprovalBar({ approvalWeight, quo
);
});

type ApprovalBarProps = {
approvals: number;
threshold: number;
approverAddresses?: string[];
};

export const ApprovalBar = React.memo(function ApprovalBar({ approvals, threshold, approverAddresses = [] }: ApprovalBarProps) {
return (
<div className="flex items-center gap-2" aria-label={`${approvals} of ${threshold} approvals`}>
<div className="flex gap-1">
{Array.from({ length: threshold }).map((_, i) => {
const isApproved = i < approvals;

// Truncate the address if this dot represents an approval
let tooltipTitle = undefined;
if (isApproved && approverAddresses[i]) {
const addr = approverAddresses[i];
tooltipTitle = `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}

return (
<div
key={i}
title={tooltipTitle} // Native HTML tooltip
className={`w-2 h-2 rounded-full ${
isApproved ? "bg-emerald-400" : "bg-zinc-700"
}`}
/>
);
})}
</div>
<span className="text-xs text-zinc-500 font-mono">
{approvals}/{threshold}
</span>
</div>
);
});
84 changes: 44 additions & 40 deletions frontend/src/components/CreateProposalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getWeightCapPct,
} from "../lib/contract";
import { displayToStroops } from "../lib/soroban";
import { VotingPowerPreview } from "./VotingPowerPreview";
import { StrKey } from "@stellar/stellar-sdk";
import type { ProposalKind } from "../types/accord";
// Testnet token addresses — swap for mainnet when ready
Expand Down Expand Up @@ -763,12 +764,13 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg

{/* Live Voting-Power Preview (MIN_OWNER_WEIGHT = 1) */}
{ownerAddress && StrKey.isValidEd25519PublicKey(ownerAddress.trim()) && (
<div className="mt-3 text-xs text-zinc-400 bg-zinc-800/40 border border-zinc-700/50 rounded-lg p-3 space-y-1">
<p className="text-zinc-300 font-medium font-sans">Live Impact Preview</p>
<p>Total voting weight will increase from <span className="font-mono text-zinc-200">{totalWeight}</span> to <span className="font-mono text-zinc-200">{totalWeight + 1}</span>.</p>
<p>New owner percentage share: <span className="font-mono text-zinc-300">{(totalWeight + 1 > 0 ? (1 / (totalWeight + 1)) * 100 : 0).toFixed(1)}%</span>.</p>
<p className="text-zinc-500 italic mt-1 font-sans">Note: The contract assigns a minimum weight of 1 (MIN_OWNER_WEIGHT) to newly added owners. Custom weights are not supported during owner creation.</p>
</div>
<VotingPowerPreview
beforeWeight={totalWeight}
afterWeight={totalWeight + 1}
totalWeight={totalWeight + 1}
type="add_owner"
note="Note: The contract assigns a minimum weight of 1 (MIN_OWNER_WEIGHT) to newly added owners. Custom weights are not supported during owner creation."
/>
)}
</div>
)}
Expand All @@ -794,25 +796,28 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg
</select>

{/* Live Quorum-Impact Warning */}
{selectedOwner && (
<div className="mt-3 text-xs space-y-1">
<div className="bg-zinc-800/40 border border-zinc-700/50 rounded-lg p-3">
<p className="text-zinc-300 font-medium font-sans mb-1">Live Impact Preview</p>
<p>Owner's current weight: <span className="font-mono text-zinc-200">{currentWeights[selectedOwner] ?? 1}</span>.</p>
<p>Resulting total voting weight: <span className="font-mono text-zinc-200">{totalWeight - (currentWeights[selectedOwner] ?? 1)}</span> (threshold: <span className="font-mono text-zinc-200">{quorumWeight}</span>).</p>
</div>
{totalWeight - (currentWeights[selectedOwner] ?? 1) < quorumWeight && (
<div className="mt-2 flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300 rounded-lg p-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0 mt-0.5">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
<p className="leading-normal font-sans">
<strong>Warning:</strong> Removing this owner drops remaining total weight ({totalWeight - (currentWeights[selectedOwner] ?? 1)}) below the required quorum threshold ({quorumWeight}). Future proposals will not be executable.
</p>
</div>
)}
</div>
)}
{selectedOwner && (() => {
const currentWeight = currentWeights[selectedOwner] ?? 1;
const resultingTotalWeight = totalWeight - currentWeight;
const isQuorumBroken = resultingTotalWeight < quorumWeight;
return (
<VotingPowerPreview
beforeWeight={currentWeight}
afterWeight={0}
totalWeight={resultingTotalWeight}
threshold={quorumWeight}
type="remove_owner"
warning={{
show: isQuorumBroken,
message: (
<p>
<strong>Warning:</strong> Removing this owner drops remaining total weight ({resultingTotalWeight}) below the required quorum threshold ({quorumWeight}). Future proposals will not be executable.
</p>
)
}}
/>
);
})()}
</div>
)}

Expand Down Expand Up @@ -884,23 +889,21 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg
const exceedsCap = newSharePct > weightCapPct;

return (
<div className="mt-3 text-xs space-y-1">
<div className="bg-zinc-800/40 border border-zinc-700/50 rounded-lg p-3">
<p className="text-zinc-300 font-medium font-sans mb-1">Live Impact Preview</p>
<p>Resulting total voting weight: <span className="font-mono text-zinc-200">{nextTotalW}</span>.</p>
<p>Owner's new share: <span className="font-mono text-zinc-200">{newSharePct.toFixed(1)}%</span> (cap: <span className="font-mono text-zinc-200">{weightCapPct}%</span>).</p>
</div>
{exceedsCap && (
<div className="mt-2 flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300 rounded-lg p-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0 mt-0.5">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
<p className="leading-normal font-sans">
<VotingPowerPreview
beforeWeight={oldW}
afterWeight={wVal}
totalWeight={nextTotalW}
weightCapPct={weightCapPct}
type="change_owner_weight"
warning={{
show: exceedsCap,
message: (
<p>
<strong>Warning:</strong> Resulting weight share ({newSharePct.toFixed(1)}%) exceeds the contract's configured max weight cap ({weightCapPct}%). This weight change proposal may be rejected by the contract upon execution.
</p>
</div>
)}
</div>
)
}}
/>
);
})()}
</div>
Expand Down Expand Up @@ -1105,6 +1108,7 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg
{step === "type" && renderTypeSelector()}
{step === "form" && renderForm()}
{step === "preview" && renderPreview()}
{step === "confirm" && renderConfirm()}
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ProposalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const KIND_LABELS: Record<ProposalKind, { title: string; badge: string }> = {
remove_owner: { title: "Remove Owner", badge: "Governance" },
change_threshold: { title: "Change Threshold", badge: "Governance" },
set_spending_limit: { title: "Set Spending Limit", badge: "Spending Limit" },
change_owner_weight: { title: "Change Owner Weight", badge: "Governance" },
};

// Colour palette per category, mirroring the pill styling used by StatusBadge.
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/VotingPowerPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { VotingPowerPreview } from "./VotingPowerPreview";

describe("VotingPowerPreview", () => {
test("renders add_owner preview correctly", () => {
const { container } = render(
<VotingPowerPreview
beforeWeight={5}
afterWeight={6}
totalWeight={6}
type="add_owner"
note="Note: Test owner addition."
/>
);
expect(container.textContent).toContain("Live Impact Preview");
expect(container.textContent).toContain("Total voting weight will increase from 5 to 6");
expect(container.textContent).toContain("New owner percentage share: 16.7%");
expect(container.textContent).toContain("Note: Test owner addition.");
expect(container).toMatchSnapshot();
});

test("renders remove_owner preview correctly with warning", () => {
const { container } = render(
<VotingPowerPreview
beforeWeight={3}
afterWeight={0}
totalWeight={7}
threshold={8}
type="remove_owner"
warning={{
show: true,
message: <p>Warning: Total weight falls below threshold!</p>,
}}
/>
);
expect(container.textContent).toContain("Live Impact Preview");
expect(container.textContent).toContain("Owner's current weight: 3");
expect(container.textContent).toContain("Resulting total voting weight: 7 (threshold: 8)");
expect(container.textContent).toContain("Warning: Total weight falls below threshold!");
expect(container).toMatchSnapshot();
});

test("renders change_owner_weight preview correctly", () => {
const { container } = render(
<VotingPowerPreview
beforeWeight={2}
afterWeight={5}
totalWeight={12}
weightCapPct={50}
type="change_owner_weight"
/>
);
expect(container.textContent).toContain("Live Impact Preview");
expect(container.textContent).toContain("Resulting total voting weight: 12");
expect(container.textContent).toContain("Owner's new share: 41.7% (cap: 50%)");
expect(container).toMatchSnapshot();
});
});
120 changes: 120 additions & 0 deletions frontend/src/components/VotingPowerPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React from "react";
import { formatWeightPercent } from "../lib/soroban";

export interface VotingPowerPreviewProps {
beforeWeight: number;
afterWeight: number;
totalWeight: number; // Resulting total weight after the action
type: "add_owner" | "remove_owner" | "change_owner_weight";
threshold?: number; // Relevant for remove_owner (quorumWeight)
weightCapPct?: number; // Relevant for change_owner_weight (weightCapPct)
note?: string;
warning?: {
show: boolean;
message: React.ReactNode;
};
}

export function VotingPowerPreview({
beforeWeight,
afterWeight,
totalWeight,
type,
threshold,
weightCapPct,
note,
warning,
}: VotingPowerPreviewProps) {
return (
<div className="mt-3 text-xs space-y-1">
<div className="bg-zinc-800/40 border border-zinc-700/50 rounded-lg p-3">
<p className="text-zinc-300 font-medium font-sans mb-1">Live Impact Preview</p>

{type === "add_owner" && (
<>
<p>
Total voting weight will increase from{" "}
<span className="font-mono text-zinc-200">{beforeWeight}</span> to{" "}
<span className="font-mono text-zinc-200">{afterWeight}</span>.
</p>
<p>
New owner percentage share:{" "}
<span className="font-mono text-zinc-300">
{formatWeightPercent(1, totalWeight)}
</span>
.
</p>
</>
)}

{type === "remove_owner" && (
<>
<p>
Owner's current weight:{" "}
<span className="font-mono text-zinc-200">{beforeWeight}</span>.
</p>
<p>
Resulting total voting weight:{" "}
<span className="font-mono text-zinc-200">{totalWeight}</span>
{threshold !== undefined && (
<>
{" "}(threshold:{" "}
<span className="font-mono text-zinc-200">{threshold}</span>)
</>
)}
.
</p>
</>
)}

{type === "change_owner_weight" && (
<>
<p>
Resulting total voting weight:{" "}
<span className="font-mono text-zinc-200">{totalWeight}</span>.
</p>
<p>
Owner's new share:{" "}
<span className="font-mono text-zinc-200">
{formatWeightPercent(afterWeight, totalWeight)}
</span>
{weightCapPct !== undefined && (
<>
{" "}(cap:{" "}
<span className="font-mono text-zinc-200">{weightCapPct}%</span>)
</>
)}
.
</p>
</>
)}

{note && (
<p className="text-zinc-500 italic mt-1 font-sans">
{note}
</p>
)}
</div>

{warning && warning.show && (
<div className="mt-2 flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300 rounded-lg p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4 shrink-0 mt-0.5"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
<div className="leading-normal font-sans">
{warning.message}
</div>
</div>
)}
</div>
);
}
Loading