+ {/* Percentage labels: always For / Against / Abstain */}
+
{segments.map((segment) => (
- {segment.label}
+ {Math.round(segment.pct)}%
))}
diff --git a/components/section/display/Hero.tsx b/components/section/display/Hero.tsx
deleted file mode 100644
index b450f93..0000000
--- a/components/section/display/Hero.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { ArrowRight, MessageSquare, ThumbsUp, Vote } from "lucide-react";
-import Link from "next/link";
-
-const flowSteps = [
- {
- icon: MessageSquare,
- title: "Forum",
- href: "https://forum.arbitrum.foundation/",
- },
- {
- icon: ThumbsUp,
- title: "Temp Check",
- href: "https://snapshot.org/#/org/arbitrum",
- },
- { icon: Vote, title: "Onchain Vote", href: "/proposals" },
-];
-
-const stats = [
- { value: "$ARB", label: "Token-Governed" },
- { value: "12", label: "Council Members" },
- { value: "2 Chains", label: "One & Nova" },
-];
-
-export default function Hero() {
- return (
-
- {/* Background gradient orbs */}
-
-
-
- {/* Main heading */}
-
-
- Empowering Arbitrum
-
-
-
- {/* Description — distills the ecosystem overview, treasury, constitution and transparency */}
-
- The ArbitrumDAO governs the ecosystem through onchain token-holder
- voting, delegate representation, and Security Council elections. The
- DAO controls the $ARB treasury and upholds the seven values of its
- Constitution, with every action recorded transparently onchain.
-
-
- {/* Governance flow + stats */}
-
- {/* From idea to onchain vote */}
-
-
- From idea to onchain vote
-
-
- {flowSteps.map(({ icon: Icon, title, href }, index) => {
- const isExternal = href.startsWith("http");
- const label = (
-
-
- {title}
-
- );
- return (
-
- {isExternal ? (
-
- {label}
-
- ) : (
-
{label}
- )}
- {index < flowSteps.length - 1 && (
-
- )}
-
- );
- })}
-
-
-
- {/* Stats */}
-
- {stats.map(({ value, label }, index) => (
-
- {index > 0 && (
-
- )}
-
-
- {value}
-
- {label}
-
-
- ))}
-
-
-
-
- );
-}
diff --git a/components/section/display/ProposalBanner.tsx b/components/section/display/ProposalBanner.tsx
new file mode 100644
index 0000000..4790f0f
--- /dev/null
+++ b/components/section/display/ProposalBanner.tsx
@@ -0,0 +1,55 @@
+import Image from "next/image";
+
+/**
+ * Page banner from the Figma frame "Proposals / Option w/ Banner and Stats"
+ * (node 405:8610): a glass panel over a clipped starfield, with a blue glow
+ * bleeding in from the left and the ballot-box illustration on the right.
+ *
+ * From lg up it shares a row with the stat cards, so it drops its fixed height
+ * and stretches to whatever that grid row is. See `HomeView`.
+ */
+export default function ProposalBanner() {
+ return (
+
+ {/* Starfield */}
+
+
+ {/* Blue glow bleeding in from the left edge */}
+
+
+ {/* Ballot-box illustration, anchored right and clipped by the banner.
+ It sits under the headline on narrow screens, beside it from sm up. */}
+
+
+
+
+
+ Proposals
+
+
+ );
+}
diff --git a/components/table/ColumnsProposals.tsx b/components/table/ColumnsProposals.tsx
index 842195c..49f9403 100644
--- a/components/table/ColumnsProposals.tsx
+++ b/components/table/ColumnsProposals.tsx
@@ -2,9 +2,9 @@
import { ColumnDef, Row } from "@tanstack/react-table";
-import { QuorumIndicator } from "@components/proposal/stages/QuorumIndicator";
import { VoteDistributionBarCompact } from "@components/proposal/stages/VoteDistributionBarCompact";
import { DataTableColumnHeader } from "@components/table/ColumnHeader";
+import { QuorumCell } from "@components/table/QuorumCell";
import { DataTableRowActions } from "@components/table/RowActions";
import { ClickableDescriptionCell } from "@components/ui/DescriptionCell";
import { GovernorBadge } from "@components/ui/GovernorBadge";
@@ -17,7 +17,6 @@ import {
import { LifecycleCell } from "@components/ui/LifecycleCell";
import { VoteDisplay } from "@components/ui/VoteDisplay";
-import { sumVoteCounts } from "@/lib/vote-utils";
import { ParsedProposal } from "@/types/proposal";
export const columns: ColumnDef
[] = [
@@ -82,34 +81,31 @@ export const columns: ColumnDef[] = [
cell: ({ row }: { row: Row }) => {
const votes = row.original.votes;
- // Calculate votes toward quorum (only For + Abstain count, not Against)
- const votesTowardQuorum = sumVoteCounts(
- votes?.forVotes,
- votes?.abstainVotes
- );
-
return (
-
-
-
-
-
-
-
-
-
- {votes?.quorum && (
-
-
-
- )}
-
+
+
+
+
+
+
+
+
);
},
- size: 180,
+ size: 120,
+ },
+ {
+ id: "quorum",
+ meta: {
+ label: "Quorum",
+ },
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }: { row: Row }) => (
+
+ ),
+ size: 120,
},
{
id: "hasVoted",
diff --git a/components/table/DataTable.tsx b/components/table/DataTable.tsx
index a3dbae3..9a6ad38 100644
--- a/components/table/DataTable.tsx
+++ b/components/table/DataTable.tsx
@@ -58,6 +58,7 @@ export function DataTable({
() => ({
proposer: xl,
votes: lg,
+ quorum: xl,
governorName: md,
lifecycle: sm,
}),
diff --git a/components/table/Pagination.tsx b/components/table/Pagination.tsx
index 5f00116..7f019e0 100644
--- a/components/table/Pagination.tsx
+++ b/components/table/Pagination.tsx
@@ -1,9 +1,4 @@
-import {
- ChevronLeftIcon,
- ChevronRightIcon,
- DoubleArrowLeftIcon,
- DoubleArrowRightIcon,
-} from "@radix-ui/react-icons";
+import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import { Table } from "@tanstack/react-table";
import {
@@ -13,87 +8,130 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/Select";
+import { cn } from "@/lib/utils";
import { Button } from "@components/ui/Button";
interface DataTablePaginationProps {
table: Table;
}
+const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50];
+
+/**
+ * Build the page-number list with ellipses: always the first and last page,
+ * plus the current page and its immediate neighbours (e.g. 1 … 4 5 6 … 20).
+ */
+function buildPageItems(
+ current: number,
+ total: number
+): (number | "ellipsis")[] {
+ if (total <= 7) {
+ return Array.from({ length: total }, (_, i) => i + 1);
+ }
+
+ const items: (number | "ellipsis")[] = [1];
+ const left = Math.max(2, current - 1);
+ const right = Math.min(total - 1, current + 1);
+
+ if (left > 2) items.push("ellipsis");
+ for (let page = left; page <= right; page++) items.push(page);
+ if (right < total - 1) items.push("ellipsis");
+ items.push(total);
+
+ return items;
+}
+
export function DataTablePagination({
table,
}: DataTablePaginationProps) {
const pageCount = Math.max(table.getPageCount(), 1);
+ const { pageIndex, pageSize } = table.getState().pagination;
+ const currentPage = pageIndex + 1;
+
+ const total = table.getFilteredRowModel().rows.length;
+ const start = total === 0 ? 0 : pageIndex * pageSize + 1;
+ const end = Math.min((pageIndex + 1) * pageSize, total);
+
+ const items = buildPageItems(currentPage, pageCount);
return (
-
-
-
-
- Rows per page
-
-
{
- table.setPageSize(Number(value));
- }}
- >
-
-
-
-
- {[10, 20, 30, 40, 50].map((pageSize) => (
-
- {pageSize}
-
- ))}
-
-
-
-
- Page {table.getState().pagination.pageIndex + 1} of {pageCount}
-
-
- table.setPageIndex(0)}
- disabled={!table.getCanPreviousPage()}
- >
- Go to first page
-
-
- table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- >
- Go to previous page
-
-
- table.nextPage()}
- disabled={!table.getCanNextPage()}
- >
- Go to next page
-
-
- table.setPageIndex(pageCount - 1)}
- disabled={!table.getCanNextPage()}
- >
- Go to last page
-
-
-
+
+ {/* Left: result summary */}
+
+ Showing {start} to {end} of {total} results
+
+
+ {/* Center: numbered pagination */}
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to previous page
+
+
+
+ {items.map((item, index) =>
+ item === "ellipsis" ? (
+
+ …
+
+ ) : (
+ table.setPageIndex(item - 1)}
+ className={cn(
+ "h-8 min-w-8 rounded-md px-2 text-sm font-medium tabular-nums transition-colors",
+ item === currentPage
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-white/10 hover:text-foreground"
+ )}
+ >
+ {item}
+
+ )
+ )}
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to next page
+
+
+
+
+ {/* Right: rows-per-page select */}
+
+
Rows
+
table.setPageSize(Number(value))}
+ >
+
+
+
+
+ {PAGE_SIZE_OPTIONS.map((size) => (
+
+ {size}
+
+ ))}
+
+
);
diff --git a/components/table/QuorumCell.tsx b/components/table/QuorumCell.tsx
new file mode 100644
index 0000000..3be0d8e
--- /dev/null
+++ b/components/table/QuorumCell.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { useInView } from "@/hooks/use-in-view";
+import { useRpcSettings } from "@/hooks/use-rpc-settings";
+import { fetchProposalQuorum } from "@/lib/governor-search";
+import { sumVoteCounts } from "@/lib/vote-utils";
+import type { ParsedProposal } from "@/types/proposal";
+import { QuorumIndicator } from "@components/proposal/stages/QuorumIndicator";
+
+/**
+ * Quorum cell for the proposals table.
+ *
+ * The indexer feed carries no quorum, so we resolve it per row:
+ * - Executed proposals necessarily reached quorum, so show a full 100% bar
+ * without any network call.
+ * - Every other status fetches `quorum(snapshotBlock)` from the governor via
+ * RPC, but only once the row scrolls into view (and only if a live refresh
+ * has not already filled it). Results are cached for the session by
+ * TanStack Query so each proposal is fetched at most once.
+ */
+export function QuorumCell({ proposal }: { proposal: ParsedProposal }) {
+ const votes = proposal.votes;
+ const isExecuted = proposal.state === "Executed";
+ const snapshotBlock = proposal.startBlock;
+ const hasSnapshot = Boolean(snapshotBlock) && snapshotBlock !== "0";
+ const existingQuorum = votes?.quorum;
+
+ const { l2Rpc } = useRpcSettings();
+ const [ref, inView] = useInView
({ rootMargin: "100px" });
+
+ // Only reach for RPC when there is something to fetch and the row is visible.
+ const needsFetch = !isExecuted && !existingQuorum && hasSnapshot;
+
+ const { data: fetchedQuorum, isLoading } = useQuery({
+ queryKey: [
+ "proposal-quorum",
+ proposal.contractAddress.toLowerCase(),
+ proposal.id,
+ snapshotBlock,
+ l2Rpc,
+ ],
+ queryFn: () =>
+ fetchProposalQuorum(l2Rpc, proposal.contractAddress, snapshotBlock),
+ enabled: needsFetch && inView,
+ staleTime: Infinity,
+ gcTime: 30 * 60 * 1000,
+ retry: 1,
+ });
+
+ // Executed: reached by definition, render a full bar with no fetch.
+ if (isExecuted) {
+ return (
+
+
+
+ );
+ }
+
+ const current = votes
+ ? sumVoteCounts(votes.forVotes, votes.abstainVotes)
+ : "0";
+ const quorum = existingQuorum ?? fetchedQuorum;
+
+ if (quorum) {
+ return (
+
+
+
+ );
+ }
+
+ // Awaiting an in-view RPC fetch: skeleton track (keeps column width stable).
+ if (needsFetch && (isLoading || !inView)) {
+ return (
+
+ );
+ }
+
+ // No snapshot block / fetch failed: nothing to show.
+ return (
+
+ -
+
+ );
+}
diff --git a/components/table/Toolbar.tsx b/components/table/Toolbar.tsx
index ba3d660..d1ca121 100644
--- a/components/table/Toolbar.tsx
+++ b/components/table/Toolbar.tsx
@@ -1,14 +1,11 @@
"use client";
-import { Plus } from "lucide-react";
-import Link from "next/link";
import { useCallback, useState } from "react";
import { Table } from "@tanstack/react-table";
import { ToolbarResetButton } from "@components/table/ToolbarResetButton";
import { ToolbarSearch } from "@components/table/ToolbarSearch";
-import { Button } from "@components/ui/Button";
interface DataTableToolbarProps {
table: Table;
@@ -34,25 +31,15 @@ export function DataTableToolbar({
}, [table]);
return (
-
-
-
-
- {isFiltered && }
-
-
-
-
-
- New Proposal
-
-
-
+
+
+
+ {isFiltered && }
);
}
diff --git a/components/table/ToolbarSearch.tsx b/components/table/ToolbarSearch.tsx
index 941a82f..f10d33b 100644
--- a/components/table/ToolbarSearch.tsx
+++ b/components/table/ToolbarSearch.tsx
@@ -22,13 +22,6 @@ export const ToolbarSearch = memo(function ToolbarSearch({
}: ToolbarSearchProps) {
return (
-
+
);
});
diff --git a/components/ui/GovernorBadge.tsx b/components/ui/GovernorBadge.tsx
index dbdc9c3..ff90c2d 100644
--- a/components/ui/GovernorBadge.tsx
+++ b/components/ui/GovernorBadge.tsx
@@ -25,37 +25,21 @@ export const GovernorBadge = memo(function GovernorBadge({
const isCore = governorType === "core";
const sizeClasses =
- size === "sm" ? "text-[10px] px-2 py-0.5" : "text-xs px-2.5 py-0.5";
+ size === "sm"
+ ? "text-[9px] px-2 py-0.5 tracking-wide"
+ : "text-[10px] px-2.5 py-1 tracking-wider";
return (
diff --git a/components/ui/LifecycleCell.tsx b/components/ui/LifecycleCell.tsx
index f7c58fa..452966a 100644
--- a/components/ui/LifecycleCell.tsx
+++ b/components/ui/LifecycleCell.tsx
@@ -15,14 +15,15 @@ import {
formatCurrentState,
formatStageName,
getEffectiveDisplayState,
+ getStateDotColor,
getStateStyle,
} from "@/lib/lifecycle-utils";
import { buildProposalPath } from "@/lib/proposal-url";
import { cn } from "@/lib/utils";
import {
- CheckCircledIcon,
ClockIcon,
CrossCircledIcon,
+ ExternalLinkIcon,
ReloadIcon,
} from "@radix-ui/react-icons";
@@ -121,19 +122,18 @@ export function LifecycleCell({ proposal }: LifecycleCellProps) {
}
function StaticLifecycleContent({ currentState }: { currentState: string }) {
- const { icon, color } = getStateStyle(currentState);
- const StateIcon = iconMap[icon];
+ const { color } = getStateStyle(currentState);
+ const dotColor = getStateDotColor(currentState);
return (
-
-
+
+
{formatCurrentState(currentState)}
+
@@ -247,26 +247,23 @@ const LifecycleContent = memo(function LifecycleContent({
// Use different styling for in-progress Core proposals
const effectiveState = isInProgress ? "pending" : currentState;
- const { icon, color } = getStateStyle(effectiveState);
- const StateIcon = iconMap[icon];
+ const { color } = getStateStyle(effectiveState);
+ const dotColor = getStateDotColor(effectiveState);
return (
-
- {isBackgroundRefreshing ? (
-
-
-
-
- ) : (
-
- )}
+
+
+
+ {isBackgroundRefreshing && (
+
+ )}
+
{stateDisplay}
+
@@ -290,10 +287,3 @@ const LifecycleContent = memo(function LifecycleContent({
return null;
});
-
-const iconMap = {
- check: CheckCircledIcon,
- reload: ReloadIcon,
- clock: ClockIcon,
- cross: CrossCircledIcon,
-} as const;
diff --git a/config/delegates.test.ts b/config/delegates.test.ts
new file mode 100644
index 0000000..df5edda
--- /dev/null
+++ b/config/delegates.test.ts
@@ -0,0 +1,93 @@
+import { EXCLUDED_DELEGATE_ADDRESSES as SDK_EXCLUDED_DELEGATE_ADDRESSES } from "@gzeoneth/gov-tracker";
+import { describe, expect, it } from "vitest";
+
+import {
+ DELEGATE_LIST_MAX_ROWS,
+ DELEGATE_MIN_VOTING_POWER_ARB,
+ DELEGATE_MIN_VOTING_POWER_WEI,
+ EXCLUDED_DELEGATE_ADDRESSES,
+ countEligibleDelegates,
+ isExcludedDelegateAddress,
+} from "./delegates";
+
+const EXCLUDED_ADDRESS = EXCLUDED_DELEGATE_ADDRESSES[0];
+
+function delegate(votingPowerArb: number, address = "0x1111") {
+ return {
+ address,
+ votingPower: (
+ BigInt(Math.round(votingPowerArb * 1000)) * BigInt("1000000000000000")
+ ).toString(),
+ };
+}
+
+describe("delegates config", () => {
+ describe("threshold", () => {
+ it("keeps the wei string in step with the ARB figure", () => {
+ expect(DELEGATE_MIN_VOTING_POWER_WEI).toBe(
+ (
+ BigInt(DELEGATE_MIN_VOTING_POWER_ARB) * BigInt("1000000000000000000")
+ ).toString()
+ );
+ });
+
+ it("asks the indexer for more rows than its silent 1,000-row default", () => {
+ expect(DELEGATE_LIST_MAX_ROWS).toBeGreaterThan(1000);
+ });
+ });
+
+ describe("isExcludedDelegateAddress", () => {
+ it("matches the SDK's exclude list", () => {
+ expect([...EXCLUDED_DELEGATE_ADDRESSES]).toEqual([
+ ...SDK_EXCLUDED_DELEGATE_ADDRESSES,
+ ]);
+ });
+
+ it("ignores address casing", () => {
+ expect(isExcludedDelegateAddress(EXCLUDED_ADDRESS.toLowerCase())).toBe(
+ true
+ );
+ expect(isExcludedDelegateAddress(EXCLUDED_ADDRESS.toUpperCase())).toBe(
+ true
+ );
+ });
+
+ it("does not match ordinary delegates", () => {
+ expect(isExcludedDelegateAddress("0xabc")).toBe(false);
+ });
+ });
+
+ describe("countEligibleDelegates", () => {
+ it("counts delegates at or above the threshold", () => {
+ const count = countEligibleDelegates([
+ delegate(DELEGATE_MIN_VOTING_POWER_ARB, "0xaaa"),
+ delegate(DELEGATE_MIN_VOTING_POWER_ARB + 1, "0xbbb"),
+ ]);
+
+ expect(count).toBe(2);
+ });
+
+ it("drops delegates below the threshold", () => {
+ const count = countEligibleDelegates([
+ delegate(DELEGATE_MIN_VOTING_POWER_ARB - 0.001, "0xaaa"),
+ delegate(0, "0xbbb"),
+ delegate(DELEGATE_MIN_VOTING_POWER_ARB, "0xccc"),
+ ]);
+
+ expect(count).toBe(1);
+ });
+
+ it("drops the governance exclude address however much it holds", () => {
+ const count = countEligibleDelegates([
+ delegate(5_000_000_000, EXCLUDED_ADDRESS),
+ delegate(DELEGATE_MIN_VOTING_POWER_ARB, "0xaaa"),
+ ]);
+
+ expect(count).toBe(1);
+ });
+
+ it("returns zero for an empty list", () => {
+ expect(countEligibleDelegates([])).toBe(0);
+ });
+ });
+});
diff --git a/config/delegates.ts b/config/delegates.ts
new file mode 100644
index 0000000..6acf9ff
--- /dev/null
+++ b/config/delegates.ts
@@ -0,0 +1,70 @@
+/**
+ * Delegate eligibility — the single rule behind every delegate count in the app.
+ *
+ * A delegated address only counts as a delegate once it holds at least
+ * {@link DELEGATE_MIN_VOTING_POWER_ARB} of voting power. The indexer tracks
+ * every address that has ever been delegated to (hundreds of thousands, most
+ * holding dust), so an unfiltered count says nothing useful about who can
+ * actually move a vote.
+ *
+ * Keep this module dependency-free: it is imported by client components and by
+ * server route handlers alike.
+ */
+
+/** Minimum voting power, in whole ARB, for an address to count as a delegate. */
+export const DELEGATE_MIN_VOTING_POWER_ARB = 5000;
+
+/**
+ * {@link DELEGATE_MIN_VOTING_POWER_ARB} in wei (ARB has 18 decimals).
+ * `config/delegates.test.ts` asserts the two stay in step.
+ */
+export const DELEGATE_MIN_VOTING_POWER_WEI = "5000000000000000000000";
+
+/**
+ * Explicit row cap for delegate-list requests.
+ *
+ * The indexer defaults to 1,000 rows and truncates silently. Roughly 775
+ * addresses currently clear the threshold, so the default would hold for now
+ * and then start under-counting without warning as the DAO grows.
+ */
+export const DELEGATE_LIST_MAX_ROWS = 100_000;
+
+/**
+ * Governance exclude address. Voting power delegated here is deliberately left
+ * out of quorum, so it is not a delegate and must not be counted as one.
+ *
+ * Duplicated from `EXCLUDED_DELEGATE_ADDRESSES` in `@gzeoneth/gov-tracker` so
+ * that server route handlers can filter without pulling the SDK's node
+ * dependencies into their bundle. `config/delegates.test.ts` asserts the two
+ * lists match.
+ */
+export const EXCLUDED_DELEGATE_ADDRESSES = [
+ "0x00000000000000000000000000000000000A4B86",
+] as const;
+
+const EXCLUDED_DELEGATE_ADDRESS_SET = new Set(
+ EXCLUDED_DELEGATE_ADDRESSES.map((address) => address.toLowerCase())
+);
+
+export function isExcludedDelegateAddress(address: string): boolean {
+ return EXCLUDED_DELEGATE_ADDRESS_SET.has(address.toLowerCase());
+}
+
+/**
+ * Count the delegates in `delegates` that meet the eligibility rule.
+ *
+ * Applies the threshold itself rather than trusting the caller's filter, so a
+ * count is correct whether the list came from the indexer pre-filtered or from
+ * on-chain refreshes that may have pushed an address below the threshold.
+ */
+export function countEligibleDelegates(
+ delegates: ReadonlyArray<{ address: string; votingPower: string }>
+): number {
+ const threshold = BigInt(DELEGATE_MIN_VOTING_POWER_WEI);
+
+ return delegates.filter(
+ (delegate) =>
+ !isExcludedDelegateAddress(delegate.address) &&
+ BigInt(delegate.votingPower) >= threshold
+ ).length;
+}
diff --git a/config/marketing.test.ts b/config/marketing.test.ts
index aaa9bb7..1373717 100644
--- a/config/marketing.test.ts
+++ b/config/marketing.test.ts
@@ -38,12 +38,12 @@ describe("marketing config", () => {
expect(delegates?.href).toBe("/delegates");
});
- it("has ArbitrumDAO nav item", () => {
- const dao = marketingConfig.mainNav.find(
- (item) => item.title === "ArbitrumDAO"
+ it("has About nav item", () => {
+ const about = marketingConfig.mainNav.find(
+ (item) => item.title === "About"
);
- expect(dao).toBeDefined();
- expect(dao?.href).toBe("https://arbitrum.foundation/governance");
+ expect(about).toBeDefined();
+ expect(about?.href).toBe("https://arbitrum.foundation/governance");
});
it("all nav items have title and href", () => {
diff --git a/config/marketing.ts b/config/marketing.ts
index 287b8b8..2f1f088 100644
--- a/config/marketing.ts
+++ b/config/marketing.ts
@@ -21,7 +21,7 @@ export const marketingConfig: MarketingConfig = {
href: "/delegates",
},
{
- title: "ArbitrumDAO",
+ title: "About",
href: "https://arbitrum.foundation/governance",
},
],
diff --git a/hooks/use-delegate-count.ts b/hooks/use-delegate-count.ts
new file mode 100644
index 0000000..ba25040
--- /dev/null
+++ b/hooks/use-delegate-count.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+export type DelegateCountResult = {
+ count: number;
+ minVotingPowerArb: number;
+ minVotingPower: string;
+};
+
+export const delegateCountKey = ["delegate-count"] as const;
+
+async function fetchDelegateCount(): Promise {
+ const response = await fetch("/api/delegate-count", {
+ headers: { accept: "application/json" },
+ });
+ if (!response.ok) {
+ throw new Error(`Delegate count request failed: ${response.status}`);
+ }
+ return (await response.json()) as DelegateCountResult;
+}
+
+/**
+ * How many delegates hold at least the app-wide minimum voting power.
+ *
+ * Served by `/api/delegate-count`, which is env-configured and answers 503 when
+ * the governance indexer is unset, so a failure here is expected in local dev
+ * and callers must render it as "no figure" rather than an error.
+ */
+export function useDelegateCount() {
+ return useQuery({
+ queryKey: delegateCountKey,
+ queryFn: fetchDelegateCount,
+ retry: false,
+ staleTime: 5 * 60 * 1000,
+ });
+}
diff --git a/hooks/use-delegate-search.ts b/hooks/use-delegate-search.ts
index 20df433..d84c618 100644
--- a/hooks/use-delegate-search.ts
+++ b/hooks/use-delegate-search.ts
@@ -1,7 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
filterDelegatesByAddress,
@@ -10,6 +10,7 @@ import {
type DelegateInfo,
} from "@gzeoneth/gov-tracker";
+import { countEligibleDelegates } from "@/config/delegates";
import { delegateVotingPowerKey } from "@/hooks/use-delegate-voting-power";
import { useRpcSettings } from "@/hooks/use-rpc-settings";
import { debug } from "@/lib/debug";
@@ -33,6 +34,12 @@ export interface UseDelegateSearchOptions {
export interface UseDelegateSearchResult {
delegates: DelegateInfo[];
+ /**
+ * Size of the eligible delegate population, independent of the search and
+ * min-power filters, so a "total delegates" figure does not shrink as the
+ * user narrows the table.
+ */
+ eligibleDelegateCount: number;
totalVotingPower: string;
totalSupply: string;
error: Error | null;
@@ -173,6 +180,14 @@ export function useDelegateSearch({
filterFromCache();
}, [minVotingPower, debouncedAddressFilter, delegateData]);
+ // Counted off the loaded list rather than `delegates` so the figure survives
+ // the search box, and re-counted after refreshVisibleDelegates writes fresh
+ // on-chain power in case a delegate has dropped below the threshold.
+ const eligibleDelegateCount = useMemo(
+ () => (delegateData ? countEligibleDelegates(delegateData.delegates) : 0),
+ [delegateData]
+ );
+
const refreshVisibleDelegates = useCallback(
async (addresses: string[]) => {
if (!enabled || !isHydrated || addresses.length === 0) return;
@@ -255,6 +270,7 @@ export function useDelegateSearch({
return {
delegates,
+ eligibleDelegateCount,
totalVotingPower,
totalSupply,
error,
diff --git a/hooks/use-in-view.ts b/hooks/use-in-view.ts
new file mode 100644
index 0000000..b5b7a40
--- /dev/null
+++ b/hooks/use-in-view.ts
@@ -0,0 +1,58 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+/** Options for useInView */
+export interface UseInViewOptions {
+ /** Margin around the root, forwarded to IntersectionObserver.rootMargin */
+ rootMargin?: string;
+ /** Stop observing once the element has been seen at least once (default: true) */
+ once?: boolean;
+}
+
+/**
+ * Track whether an element is within the viewport via IntersectionObserver.
+ *
+ * Returns a ref to attach to the element and a boolean that flips to true once
+ * the element intersects. With `once` (the default) the observer disconnects
+ * after the first intersection, so it is cheap to use on many table rows that
+ * only need to trigger a one-time lazy fetch.
+ */
+export function useInView(
+ options: UseInViewOptions = {}
+): [React.RefObject, boolean] {
+ const { rootMargin = "0px", once = true } = options;
+ const ref = useRef(null);
+ const [inView, setInView] = useState(false);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+
+ // SSR / very old browsers: assume visible so content is never withheld.
+ if (typeof IntersectionObserver === "undefined") {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time fallback when the observer API is missing
+ setInView(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry) return;
+ if (entry.isIntersecting) {
+ setInView(true);
+ if (once) observer.disconnect();
+ } else if (!once) {
+ setInView(false);
+ }
+ },
+ { rootMargin }
+ );
+
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [rootMargin, once]);
+
+ return [ref, inView];
+}
diff --git a/lib/badge-colors.test.ts b/lib/badge-colors.test.ts
index 46f3dd7..1d0af7d 100644
--- a/lib/badge-colors.test.ts
+++ b/lib/badge-colors.test.ts
@@ -29,18 +29,19 @@ describe("badge-colors", () => {
}
});
- it("for uses emerald colors", () => {
- expect(VOTE_COLORS.for.text).toContain("emerald");
- expect(VOTE_COLORS.for.bg).toContain("emerald");
+ it("for uses the accent (teal-green) token", () => {
+ expect(VOTE_COLORS.for.text).toContain("arb-accent2");
+ expect(VOTE_COLORS.for.bg).toContain("arb-accent2");
});
- it("against uses rose colors", () => {
- expect(VOTE_COLORS.against.text).toContain("rose");
- expect(VOTE_COLORS.against.bg).toContain("rose");
+ it("against uses the error (red-orange) token", () => {
+ expect(VOTE_COLORS.against.text).toContain("arb-error");
+ expect(VOTE_COLORS.against.bg).toContain("arb-error");
});
- it("abstain uses gray/muted colors", () => {
- expect(VOTE_COLORS.abstain.bg).toContain("gray");
+ it("abstain uses muted/neutral colors", () => {
+ expect(VOTE_COLORS.abstain.text).toContain("muted-foreground");
+ expect(VOTE_COLORS.abstain.bg).toContain("white");
});
});
@@ -62,14 +63,14 @@ describe("badge-colors", () => {
}
});
- it("reached uses emerald colors", () => {
- expect(QUORUM_COLORS.reached.text).toContain("emerald");
- expect(QUORUM_COLORS.reached.icon).toContain("emerald");
+ it("reached uses the accent (teal-green) token", () => {
+ expect(QUORUM_COLORS.reached.text).toContain("arb-accent2");
+ expect(QUORUM_COLORS.reached.icon).toContain("arb-accent2");
});
- it("pending uses arb brand colors", () => {
- expect(QUORUM_COLORS.pending.text).toContain("arb-blue");
- expect(QUORUM_COLORS.pending.icon).toContain("arb-blue");
+ it("pending uses the brand (blue) token", () => {
+ expect(QUORUM_COLORS.pending.text).toContain("arb-brand");
+ expect(QUORUM_COLORS.pending.icon).toContain("arb-brand");
});
});
@@ -104,16 +105,8 @@ describe("badge-colors", () => {
});
describe("dark mode support", () => {
- it("vote colors include dark mode variants", () => {
- expect(VOTE_COLORS.for.text).toContain("dark:");
- expect(VOTE_COLORS.against.text).toContain("dark:");
- });
-
- it("quorum colors include dark mode variants", () => {
- expect(QUORUM_COLORS.reached.text).toContain("dark:");
- expect(QUORUM_COLORS.pending.text).toContain("dark:");
- });
-
+ // Vote and quorum colors now use single-theme Figma palette tokens
+ // (the app is fixed to dark mode), so they no longer carry dark: variants.
it("status badge colors include dark mode variants", () => {
expect(STATUS_BADGE_COLORS.success).toContain("dark:");
expect(STATUS_BADGE_COLORS.warning).toContain("dark:");
diff --git a/lib/badge-colors.ts b/lib/badge-colors.ts
index 676f757..52e2631 100644
--- a/lib/badge-colors.ts
+++ b/lib/badge-colors.ts
@@ -3,47 +3,44 @@
* Use these constants to maintain consistency across the UI.
*/
-// Vote type colors - using emerald/rose for semantic meaning
+// Vote type colors - aligned to the Figma "Proposals" palette
+// (For = accent teal-green, Against = error red-orange, Abstain = muted grey).
export const VOTE_COLORS = {
for: {
- text: "text-emerald-600 dark:text-emerald-400",
- bg: "bg-emerald-500 dark:bg-emerald-400",
- dot: "bg-emerald-500 dark:bg-emerald-400",
- gradient:
- "bg-gradient-to-r from-emerald-500 to-emerald-400 dark:from-emerald-400 dark:to-emerald-300",
+ text: "text-arb-accent2",
+ bg: "bg-arb-accent2",
+ dot: "bg-arb-accent2",
+ gradient: "bg-arb-accent2",
},
against: {
- text: "text-rose-600 dark:text-rose-400",
- bg: "bg-rose-500 dark:bg-rose-400",
- dot: "bg-rose-500 dark:bg-rose-400",
- gradient:
- "bg-gradient-to-r from-rose-500 to-rose-400 dark:from-rose-400 dark:to-rose-300",
+ text: "text-arb-error",
+ bg: "bg-arb-error",
+ dot: "bg-arb-error",
+ gradient: "bg-arb-error",
},
abstain: {
text: "text-muted-foreground",
- bg: "bg-gray-400 dark:bg-gray-500",
- dot: "bg-gray-400 dark:bg-gray-500",
- gradient:
- "bg-gradient-to-r from-gray-400 to-gray-300 dark:from-gray-500 dark:to-gray-400",
+ bg: "bg-white/30",
+ dot: "bg-white/30",
+ gradient: "bg-white/25",
},
} as const;
// Quorum status colors
export const QUORUM_COLORS = {
reached: {
- text: "text-emerald-600 dark:text-emerald-400",
- bg: "bg-emerald-500/20",
- ring: "ring-emerald-500/30",
- icon: "text-emerald-500",
- gradient:
- "bg-gradient-to-r from-emerald-500 to-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.5)]",
+ text: "text-arb-accent2",
+ bg: "bg-arb-accent2/20",
+ ring: "ring-arb-accent2/30",
+ icon: "text-arb-accent2",
+ gradient: "bg-arb-accent2 shadow-[0_0_8px_rgba(60,200,160,0.5)]",
},
pending: {
- text: "text-arb-blue dark:text-arb-teal",
- bg: "bg-arb-blue/20",
- ring: "ring-arb-blue/30",
- icon: "text-arb-blue",
- gradient: "bg-gradient-to-r from-arb-blue to-arb-teal",
+ text: "text-arb-brand",
+ bg: "bg-arb-brand/20",
+ ring: "ring-arb-brand/30",
+ icon: "text-arb-brand",
+ gradient: "bg-arb-brand",
},
} as const;
diff --git a/lib/delegate-cache.test.ts b/lib/delegate-cache.test.ts
index cd144f5..3cb3407 100644
--- a/lib/delegate-cache.test.ts
+++ b/lib/delegate-cache.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
+import { DELEGATE_MIN_VOTING_POWER_WEI } from "@/config/delegates";
import type { DelegateCache, DelegateInfo } from "@/types/delegate";
import {
@@ -11,6 +12,11 @@ import {
type TallyDelegateListItem,
} from "./delegate-cache";
+const ELIGIBLE_VOTING_POWER = DELEGATE_MIN_VOTING_POWER_WEI;
+const INELIGIBLE_VOTING_POWER = (
+ BigInt(DELEGATE_MIN_VOTING_POWER_WEI) - BigInt(1)
+).toString();
+
// Mock cache data for testing
const createMockDelegate = (
address: `0x${string}`,
@@ -79,9 +85,12 @@ describe("delegate-cache", () => {
const delegates = [
createMockDelegate(
"0x1111111111111111111111111111111111111111",
- "1000"
+ ELIGIBLE_VOTING_POWER
+ ),
+ createMockDelegate(
+ "0x2222222222222222222222222222222222222222",
+ ELIGIBLE_VOTING_POWER
),
- createMockDelegate("0x2222222222222222222222222222222222222222", "500"),
];
const cache = createMockCache(delegates);
@@ -95,6 +104,25 @@ describe("delegate-cache", () => {
expect(stats.age).toBeDefined();
});
+ it("counts only delegates that meet the eligibility threshold", () => {
+ // The SDK builds the cache with a lower floor of its own, so its
+ // stats.totalDelegates over-counts by this app's rule.
+ const cache = createMockCache([
+ createMockDelegate(
+ "0x1111111111111111111111111111111111111111",
+ ELIGIBLE_VOTING_POWER
+ ),
+ createMockDelegate(
+ "0x2222222222222222222222222222222222222222",
+ INELIGIBLE_VOTING_POWER
+ ),
+ createMockDelegate("0x00000000000000000000000000000000000a4b86", "1"),
+ ]);
+
+ expect(cache.stats.totalDelegates).toBe(3);
+ expect(getDelegateCacheStats(cache).totalDelegates).toBe(1);
+ });
+
it("formats age correctly", () => {
const cache = createMockCache([]);
const stats = getDelegateCacheStats(cache);
diff --git a/lib/delegate-cache.ts b/lib/delegate-cache.ts
index 3fd1e02..ec74a99 100644
--- a/lib/delegate-cache.ts
+++ b/lib/delegate-cache.ts
@@ -7,7 +7,6 @@
*/
import {
- EXCLUDED_DELEGATE_ADDRESSES,
getDelegateCacheStats as sdkGetDelegateCacheStats,
getDelegateRankInfo as sdkGetDelegateRankInfo,
getTopDelegates as sdkGetTopDelegates,
@@ -16,6 +15,11 @@ import {
type DelegateInfo,
} from "@gzeoneth/gov-tracker";
+import {
+ DELEGATE_MIN_VOTING_POWER_WEI,
+ countEligibleDelegates,
+ isExcludedDelegateAddress,
+} from "@/config/delegates";
import { STORAGE_KEYS } from "@/config/storage-keys";
import {
getAddressDisplayRecord,
@@ -44,12 +48,6 @@ import { debug } from "./debug";
import { formatCacheAge } from "./format-utils";
import { getStoredValue } from "./storage-utils";
-const DEFAULT_MIN_VOTING_POWER = "10000000000000000000";
-
-const EXCLUDED_DELEGATE_ADDRESS_SET = new Set(
- EXCLUDED_DELEGATE_ADDRESSES.map((address) => address.toLowerCase())
-);
-
/**
* Remove the governance exclude address (0x...0A4B86) from a delegate list.
*
@@ -61,7 +59,7 @@ export function stripExcludedDelegates(
result: TallyDelegateListResult
): TallyDelegateListResult {
const excluded = result.delegates.filter((delegate) =>
- EXCLUDED_DELEGATE_ADDRESS_SET.has(delegate.address.toLowerCase())
+ isExcludedDelegateAddress(delegate.address)
);
if (excluded.length === 0) return result;
@@ -74,8 +72,7 @@ export function stripExcludedDelegates(
return {
...result,
delegates: result.delegates.filter(
- (delegate) =>
- !EXCLUDED_DELEGATE_ADDRESS_SET.has(delegate.address.toLowerCase())
+ (delegate) => !isExcludedDelegateAddress(delegate.address)
),
totalVotingPower: (remainingPower > BigInt(0)
? remainingPower
@@ -157,7 +154,10 @@ export function getDelegateCacheStats(
const generatedAt = new Date(sdkStats.generatedAt);
return {
- totalDelegates: sdkStats.totalDelegates,
+ // Not sdkStats.totalDelegates: the cache is built with the SDK's own
+ // (lower) floor, so counting it directly would disagree with every other
+ // delegate figure in the app.
+ totalDelegates: countEligibleDelegates(cache.delegates),
snapshotBlock: sdkStats.snapshotBlock,
generatedAt,
age: formatCacheAge(generatedAt),
@@ -173,8 +173,15 @@ export function getTopDelegates(
return sdkGetTopDelegates(cache, limit);
}
+/**
+ * Load the eligible delegate population.
+ *
+ * Defaults to the app-wide threshold rather than the indexer's near-zero floor,
+ * so a caller counting `delegates.length` gets the same figure as every other
+ * delegate count in the app.
+ */
export async function loadDelegateList(
- minVotingPower = DEFAULT_MIN_VOTING_POWER
+ minVotingPower = DELEGATE_MIN_VOTING_POWER_WEI
): Promise {
const result = await getTallyDataClient().getDelegateList(minVotingPower);
return stripExcludedDelegates(result);
@@ -186,7 +193,9 @@ export function getDelegateListStats(
const generatedAt = new Date();
return {
- totalDelegates: delegateList.delegates.length,
+ // Re-applies the threshold rather than trusting the list's own filter: a
+ // caller may have requested a lower floor.
+ totalDelegates: countEligibleDelegates(delegateList.delegates),
snapshotBlock: 0,
generatedAt,
age: formatCacheAge(generatedAt),
diff --git a/lib/governor-search/index.ts b/lib/governor-search/index.ts
index cf5f02e..a7a587b 100644
--- a/lib/governor-search/index.ts
+++ b/lib/governor-search/index.ts
@@ -1,3 +1,4 @@
+export { fetchProposalQuorum } from "./quorum";
export {
parseProposals,
refreshProposalStates,
diff --git a/lib/governor-search/quorum.ts b/lib/governor-search/quorum.ts
new file mode 100644
index 0000000..33c48da
--- /dev/null
+++ b/lib/governor-search/quorum.ts
@@ -0,0 +1,36 @@
+import { ethers } from "ethers";
+
+import { createRpcProvider } from "@/lib/rpc-utils";
+import OZGovernor_ABI from "@data/OzGovernor_ABI.json";
+
+/**
+ * Governor contracts are immutable per (rpcUrl, address), so reuse instances
+ * across the many per-row quorum lookups the table issues.
+ */
+const contractCache = new Map();
+
+/**
+ * Fetch a proposal's quorum requirement (in ARB wei) from its governor.
+ *
+ * Quorum is a function of the votable supply at the proposal's vote-start
+ * (snapshot) block, so it is fixed for a given proposal and safe to cache.
+ * The snapshot block is the proposal's `startBlock`, matching the argument the
+ * bulk RPC refresh already passes to `quorum()`.
+ */
+export async function fetchProposalQuorum(
+ rpcUrl: string,
+ governorAddress: string,
+ snapshotBlock: string
+): Promise {
+ const provider = await createRpcProvider(rpcUrl);
+
+ const cacheKey = `${rpcUrl}:${governorAddress.toLowerCase()}`;
+ let contract = contractCache.get(cacheKey);
+ if (!contract) {
+ contract = new ethers.Contract(governorAddress, OZGovernor_ABI, provider);
+ contractCache.set(cacheKey, contract);
+ }
+
+ const quorum = await contract.quorum(snapshotBlock);
+ return quorum.toString();
+}
diff --git a/lib/lifecycle-utils.ts b/lib/lifecycle-utils.ts
index a5e6147..a43575d 100644
--- a/lib/lifecycle-utils.ts
+++ b/lib/lifecycle-utils.ts
@@ -187,3 +187,25 @@ export function getStateStyle(state: string | null): {
if (!state) return DEFAULT_STATE_STYLE;
return STATE_STYLE_MAP[state.toLowerCase()] ?? DEFAULT_STATE_STYLE;
}
+
+/** Background-color class for the status dot, matching each state's hue. */
+const STATE_DOT_MAP: Record = {
+ executed: "bg-green-500",
+ active: "bg-blue-500",
+ pending: "bg-blue-500",
+ queued: "bg-yellow-500",
+ succeeded: "bg-yellow-500",
+ defeated: "bg-red-500",
+ canceled: "bg-red-500",
+ expired: "bg-red-500",
+};
+
+/**
+ * Get the status dot background color class for a proposal state.
+ * @param state - The proposal state
+ * @returns A Tailwind bg-* class for the 8px status dot
+ */
+export function getStateDotColor(state: string | null): string {
+ if (!state) return "bg-muted-foreground";
+ return STATE_DOT_MAP[state.toLowerCase()] ?? "bg-muted-foreground";
+}
diff --git a/lib/tally-data/indexer.test.ts b/lib/tally-data/indexer.test.ts
index d316964..15f7b0b 100644
--- a/lib/tally-data/indexer.test.ts
+++ b/lib/tally-data/indexer.test.ts
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ DELEGATE_LIST_MAX_ROWS,
+ DELEGATE_MIN_VOTING_POWER_WEI,
+} from "@/config/delegates";
import { IndexerTallyDataClient } from "@/lib/tally-data/indexer";
function mockJsonFetch(body: unknown, status = 200) {
@@ -23,14 +27,31 @@ describe("IndexerTallyDataClient", () => {
totalSupply: "0",
});
- await new IndexerTallyDataClient().getDelegateList("42");
+ await new IndexerTallyDataClient().getDelegateList("42", 7);
expect(fetchMock).toHaveBeenCalledWith(
- "/api/governance-indexer/api/tally/delegates?minVotingPower=42",
+ "/api/governance-indexer/api/tally/delegates?minVotingPower=42&limit=7",
{ headers: { accept: "application/json" } }
);
});
+ it("defaults delegate list requests to the eligibility threshold and an explicit row cap", async () => {
+ const fetchMock = mockJsonFetch({
+ delegates: [],
+ totalVotingPower: "0",
+ totalSupply: "0",
+ });
+
+ await new IndexerTallyDataClient().getDelegateList();
+
+ const url = new URL(String(fetchMock.mock.calls[0][0]), "https://app.test");
+ expect(url.searchParams.get("minVotingPower")).toBe(
+ DELEGATE_MIN_VOTING_POWER_WEI
+ );
+ // The indexer silently truncates at 1,000 rows without an explicit limit.
+ expect(Number(url.searchParams.get("limit"))).toBe(DELEGATE_LIST_MAX_ROWS);
+ });
+
it("fetches aggregated vote summaries from the app route, not the proxy", async () => {
const entries = [
{
diff --git a/lib/tally-data/indexer.ts b/lib/tally-data/indexer.ts
index b8690b6..4a49309 100644
--- a/lib/tally-data/indexer.ts
+++ b/lib/tally-data/indexer.ts
@@ -1,5 +1,9 @@
"use client";
+import {
+ DELEGATE_LIST_MAX_ROWS,
+ DELEGATE_MIN_VOTING_POWER_WEI,
+} from "@/config/delegates";
import type {
TallyAddressDisplayRecord,
TallyCandidateSummary,
@@ -78,9 +82,12 @@ async function fetchAddressMap(
}
export class IndexerTallyDataClient implements TallyDataClient {
- getDelegateList(minVotingPower = "10000000000000000000") {
+ getDelegateList(
+ minVotingPower = DELEGATE_MIN_VOTING_POWER_WEI,
+ limit = DELEGATE_LIST_MAX_ROWS
+ ) {
return fetchIndexer(
- `api/tally/delegates${queryString({ minVotingPower })}`
+ `api/tally/delegates${queryString({ minVotingPower, limit })}`
);
}
diff --git a/lib/tally-data/types.ts b/lib/tally-data/types.ts
index 7ca4587..7475987 100644
--- a/lib/tally-data/types.ts
+++ b/lib/tally-data/types.ts
@@ -120,7 +120,10 @@ export type TallyDataStats = {
};
export interface TallyDataClient {
- getDelegateList(minVotingPower?: string): Promise;
+ getDelegateList(
+ minVotingPower?: string,
+ limit?: number
+ ): Promise;
getDelegate(address: string): Promise;
getDelegateSummaries(
addresses: string[]
diff --git a/public/logos/arbitrum-foundation.svg b/public/logos/arbitrum-foundation.svg
new file mode 100644
index 0000000..677227a
--- /dev/null
+++ b/public/logos/arbitrum-foundation.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/proposals/banner-glow.svg b/public/proposals/banner-glow.svg
new file mode 100644
index 0000000..6520f72
--- /dev/null
+++ b/public/proposals/banner-glow.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/proposals/banner-illustration.png b/public/proposals/banner-illustration.png
new file mode 100644
index 0000000..46567fb
Binary files /dev/null and b/public/proposals/banner-illustration.png differ
diff --git a/public/proposals/illustration.png b/public/proposals/illustration.png
new file mode 100644
index 0000000..cec83fd
Binary files /dev/null and b/public/proposals/illustration.png differ
diff --git a/public/proposals/stars-bg.svg b/public/proposals/stars-bg.svg
new file mode 100644
index 0000000..d802b1a
--- /dev/null
+++ b/public/proposals/stars-bg.svg
@@ -0,0 +1,839 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/styles/globals.css b/styles/globals.css
index a2c2e4c..69d0708 100644
--- a/styles/globals.css
+++ b/styles/globals.css
@@ -12,21 +12,21 @@
height: 8px;
}
::-webkit-scrollbar-track {
- background: #f1f1f1;
+ background: transparent;
}
::-webkit-scrollbar-thumb {
- background: #888;
+ background: rgba(255, 255, 255, 0.14);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
- background: #55555595;
+ background: rgba(255, 255, 255, 0.24);
}
* {
scrollbar-width: thin;
- scrollbar-color: #88888868 #f1f1f100;
+ scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
}
@layer base {
@@ -63,32 +63,35 @@
}
.dark {
- --background: 222 85% 7%;
- --foreground: 200 20% 95%;
- --card: 222 75% 9%;
- --card-foreground: 200 20% 95%;
- --popover: 222 75% 9%;
- --popover-foreground: 200 20% 95%;
- --primary: 188 100% 53%;
- --primary-foreground: 222 85% 13%;
- --secondary: 222 50% 15%;
- --secondary-foreground: 200 20% 95%;
- --muted: 222 50% 15%;
- --muted-foreground: 210 15% 60%;
- --accent: 212 99% 45%;
- --accent-foreground: 200 20% 95%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 200 20% 95%;
- --border: 222 40% 18%;
- --input: 222 40% 18%;
- --ring: 188 100% 53%;
+ /* Figma "Proposals" dark palette:
+ bg #0b0c10, surface #111e30, brand #28a0f0, accent #3cc8a0,
+ error #dc5a3c, text #e8f0f8. */
+ --background: 228 19% 5%;
+ --foreground: 210 53% 94%;
+ --card: 215 48% 13%;
+ --card-foreground: 210 53% 94%;
+ --popover: 215 48% 13%;
+ --popover-foreground: 210 53% 94%;
+ --primary: 204 87% 55%;
+ --primary-foreground: 0 0% 100%;
+ --secondary: 215 48% 13%;
+ --secondary-foreground: 210 53% 94%;
+ --muted: 215 40% 14%;
+ --muted-foreground: 214 15% 66%;
+ --accent: 216 33% 18%;
+ --accent-foreground: 210 53% 94%;
+ --destructive: 11 70% 55%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 216 16% 16%;
+ --input: 216 16% 16%;
+ --ring: 204 87% 55%;
/* Glass effect variables - dark mode */
- --glass-bg: rgba(15, 23, 42, 0.6);
- --glass-bg-subtle: rgba(5, 22, 61, 0.3);
- --glass-border: rgba(16, 225, 255, 0.08);
- --shadow-glass: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
- --shadow-card-hover: 0 20px 40px -15px rgba(1, 107, 229, 0.3);
+ --glass-bg: rgba(17, 30, 48, 0.6);
+ --glass-bg-subtle: rgba(255, 255, 255, 0.04);
+ --glass-border: rgba(255, 255, 255, 0.08);
+ --shadow-glass: 0 8px 32px 0 rgba(0, 0, 0, 0.45);
+ --shadow-card-hover: 0 20px 40px -15px rgba(40, 160, 240, 0.35);
}
}
@@ -168,13 +171,13 @@
background: rgba(255, 255, 255, 0.95);
}
.dark .glass {
- background: rgba(5, 22, 61, 0.98);
+ background: rgba(17, 30, 48, 0.98);
}
.glass-subtle {
background: rgba(255, 255, 255, 0.9);
}
.dark .glass-subtle {
- background: rgba(5, 22, 61, 0.95);
+ background: rgba(23, 33, 51, 0.96);
}
}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 4b41f7c..b17de5a 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -104,6 +104,13 @@ const config = {
green: "#00E340",
yellow: "#FFE103",
orange: "#FF4400",
+ // Figma "Proposals" dark-theme tokens
+ brand: "#28A0F0",
+ accent2: "#3CC8A0",
+ error: "#DC5A3C",
+ ink: "#0B0C10",
+ surface: "#111E30",
+ line: "#212121",
},
},
borderRadius: {