diff --git a/src/lib/proposal-tabs.test.ts b/src/lib/proposal-tabs.test.ts
new file mode 100644
index 000000000..5afb48bdf
--- /dev/null
+++ b/src/lib/proposal-tabs.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ availableProposalTabs,
+ parseProposalTab,
+ PROPOSAL_TAB_KEYS,
+ PROPOSAL_TAB_LABELS,
+} from './proposal-tabs'
+
+describe('availableProposalTabs', () => {
+ it('lists all four tabs in reading order when propdates are supported', () => {
+ expect(availableProposalTabs({ propdatesSupported: true })).toEqual([
+ 'proposal',
+ 'transactions',
+ 'votes',
+ 'propdates',
+ ])
+ })
+
+ it('drops the propdates tab on chains without EAS support', () => {
+ expect(availableProposalTabs({ propdatesSupported: false })).toEqual([
+ 'proposal',
+ 'transactions',
+ 'votes',
+ ])
+ })
+})
+
+describe('parseProposalTab', () => {
+ const all = availableProposalTabs({ propdatesSupported: true })
+ const noPropdates = availableProposalTabs({ propdatesSupported: false })
+
+ it('returns a valid tab unchanged', () => {
+ expect(parseProposalTab('votes', all)).toBe('votes')
+ })
+
+ it('falls back to the first tab when the param is absent', () => {
+ expect(parseProposalTab(null, all)).toBe('proposal')
+ expect(parseProposalTab(undefined, all)).toBe('proposal')
+ expect(parseProposalTab('', all)).toBe('proposal')
+ })
+
+ it('falls back to the first tab for an unknown value', () => {
+ expect(parseProposalTab('bogus', all)).toBe('proposal')
+ })
+
+ it('normalises case and surrounding whitespace', () => {
+ expect(parseProposalTab('VOTES', all)).toBe('votes')
+ expect(parseProposalTab(' Propdates ', all)).toBe('propdates')
+ })
+
+ it('rejects a tab that is valid but not currently available', () => {
+ expect(parseProposalTab('propdates', noPropdates)).toBe('proposal')
+ })
+
+ it('falls back to the canonical first key when nothing is available', () => {
+ expect(parseProposalTab('votes', [])).toBe('proposal')
+ })
+})
+
+describe('PROPOSAL_TAB_LABELS', () => {
+ it('labels every tab key in English', () => {
+ expect(PROPOSAL_TAB_KEYS.map((k) => PROPOSAL_TAB_LABELS[k])).toEqual([
+ 'Proposal',
+ 'Transactions',
+ 'Votes',
+ 'Propdates',
+ ])
+ })
+})
diff --git a/src/lib/proposal-tabs.ts b/src/lib/proposal-tabs.ts
new file mode 100644
index 000000000..a97debd1f
--- /dev/null
+++ b/src/lib/proposal-tabs.ts
@@ -0,0 +1,49 @@
+/**
+ * Pure tab model for the proposal detail page. Kept out of the React tree so
+ * the "which tabs exist" and "what does ?tab= resolve to" rules are unit
+ * testable under the repo's node-environment vitest setup.
+ */
+
+export const PROPOSAL_TAB_KEYS = [
+ 'proposal',
+ 'transactions',
+ 'votes',
+ 'propdates',
+] as const
+
+export type ProposalTabKey = (typeof PROPOSAL_TAB_KEYS)[number]
+
+export const PROPOSAL_TAB_LABELS: Record
= {
+ proposal: 'Proposal',
+ transactions: 'Transactions',
+ votes: 'Votes',
+ propdates: 'Propdates',
+}
+
+/**
+ * Tabs to render, in reading order. Propdates are attestation-backed, so the
+ * tab only exists on chains where EAS is deployed.
+ */
+export function availableProposalTabs({
+ propdatesSupported,
+}: {
+ propdatesSupported: boolean
+}): ProposalTabKey[] {
+ return PROPOSAL_TAB_KEYS.filter((key) => key !== 'propdates' || propdatesSupported)
+}
+
+/**
+ * Resolve a `?tab=` value to a tab that is actually rendered. Unknown values,
+ * and tabs hidden on this chain, fall back to the first available tab so a
+ * stale or hand-edited link still lands somewhere useful.
+ */
+export function parseProposalTab(
+ raw: string | null | undefined,
+ available: readonly ProposalTabKey[]
+): ProposalTabKey {
+ const fallback = available[0] ?? PROPOSAL_TAB_KEYS[0]
+ if (!raw) return fallback
+ const normalised = raw.trim().toLowerCase()
+ const match = available.find((key) => key === normalised)
+ return match ?? fallback
+}