From 9141ac4521c8b53d8865774090ba9fb65246de81 Mon Sep 17 00:00:00 2001
From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com>
Date: Sat, 25 Jul 2026 01:16:56 +0400
Subject: [PATCH] Add public proposal draft experience
Let authors publish, unpublish, share, and continue editing proposal drafts while exposing public discovery and read-only draft pages. Reuse shared publication controls, draft cards, routes, and governance identity statuses across the interface.
---
src/app/AppRoutes.tsx | 4 +
src/app/auth/AuthContext.tsx | 79 +++--
src/components/GovernanceStatusPills.tsx | 19 ++
src/components/ProposalPageHeader.tsx | 2 +
src/components/ProposalStageBar.tsx | 4 +
src/components/StageChip.css | 6 +
src/components/StageChip.tsx | 1 +
src/lib/apiClient.ts | 72 ++++
src/lib/humanNodesUi.ts | 43 ++-
src/pages/feed/hooks/useFeedChamberFilters.ts | 12 +-
src/pages/human-nodes/HumanNode.tsx | 7 +
src/pages/human-nodes/HumanNodes.tsx | 3 +-
.../human-nodes/components/HumanNodeHero.tsx | 21 +-
.../components/HumanNodesResultsCard.tsx | 4 +
src/pages/initiatives/Initiative.tsx | 4 +
src/pages/profile/Profile.tsx | 5 +
src/pages/profile/components/ProfileHero.tsx | 19 +-
src/pages/proposals/ProposalCreation.tsx | 121 ++++++-
src/pages/proposals/ProposalDraft.tsx | 47 +--
src/pages/proposals/ProposalDrafts.tsx | 61 ++--
src/pages/proposals/ProposalFinished.tsx | 5 +-
src/pages/proposals/Proposals.tsx | 29 +-
src/pages/proposals/PublicDraft.tsx | 125 +++++++
src/pages/proposals/PublicDrafts.tsx | 212 ++++++++++++
src/pages/proposals/draft/CopyLinkButton.tsx | 46 +++
.../draft/DraftPublicationActions.tsx | 135 ++++++++
src/pages/proposals/draft/OwnerDraftCard.tsx | 68 ++++
.../draft/ProposalDraftDetailsCard.tsx | 245 ++++---------
src/pages/proposals/draft/PublicDraftCard.tsx | 56 +++
.../proposals/draft/PublicDraftsSection.tsx | 24 ++
src/pages/proposals/draft/draftUi.ts | 66 ++++
.../draft/useDraftPublicationActions.ts | 107 ++++++
src/pages/proposals/draft/usePublicDrafts.ts | 43 +++
.../proposalCreation/ProposalWizardShell.tsx | 32 +-
.../proposalCreation/publicationReadiness.ts | 15 +
.../proposalCreation/submitErrorRouting.ts | 17 +
.../useProposalDraftHydration.ts | 3 +
.../proposals/proposalCreation/wizardModel.ts | 13 +-
src/types/api.ts | 50 +++
src/types/stages.ts | 5 +-
tests/e2e/proposal-wizard.spec.ts | 142 ++++++++
tests/e2e/public-drafts.spec.ts | 321 ++++++++++++++++++
tests/unit/human-nodes-ui.test.ts | 29 +-
tests/unit/proposal-draft-ui.test.ts | 46 +++
tests/unit/proposal-stage-navigation.test.ts | 15 +
tests/unit/proposal-wizard-model.test.ts | 43 +++
.../proposal-wizard-submit-routing.test.ts | 17 +
47 files changed, 2098 insertions(+), 345 deletions(-)
create mode 100644 src/components/GovernanceStatusPills.tsx
create mode 100644 src/pages/proposals/PublicDraft.tsx
create mode 100644 src/pages/proposals/PublicDrafts.tsx
create mode 100644 src/pages/proposals/draft/CopyLinkButton.tsx
create mode 100644 src/pages/proposals/draft/DraftPublicationActions.tsx
create mode 100644 src/pages/proposals/draft/OwnerDraftCard.tsx
create mode 100644 src/pages/proposals/draft/PublicDraftCard.tsx
create mode 100644 src/pages/proposals/draft/PublicDraftsSection.tsx
create mode 100644 src/pages/proposals/draft/draftUi.ts
create mode 100644 src/pages/proposals/draft/useDraftPublicationActions.ts
create mode 100644 src/pages/proposals/draft/usePublicDrafts.ts
create mode 100644 src/pages/proposals/proposalCreation/publicationReadiness.ts
create mode 100644 tests/e2e/public-drafts.spec.ts
create mode 100644 tests/unit/proposal-draft-ui.test.ts
diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
index bb32730..ec836b3 100644
--- a/src/app/AppRoutes.tsx
+++ b/src/app/AppRoutes.tsx
@@ -31,6 +31,8 @@ import ProposalCreation from "../pages/proposals/ProposalCreation";
import MyGovernance from "../pages/MyGovernance";
import ProposalDrafts from "../pages/proposals/ProposalDrafts";
import ProposalDraft from "../pages/proposals/ProposalDraft";
+import PublicDraft from "../pages/proposals/PublicDraft";
+import PublicDrafts from "../pages/proposals/PublicDrafts";
import FullHistory from "../pages/human-nodes/FullHistory";
import Landing from "../pages/Landing";
import Paper from "../pages/Paper";
@@ -92,6 +94,8 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/app/auth/AuthContext.tsx b/src/app/auth/AuthContext.tsx
index dcd3c6f..96e350e 100644
--- a/src/app/auth/AuthContext.tsx
+++ b/src/app/auth/AuthContext.tsx
@@ -21,6 +21,7 @@ import {
getPolkadotAccounts,
signPolkadotMessage,
} from "@/lib/polkadotExtension";
+import { governanceIdentityStatuses } from "@/lib/humanNodesUi";
type AuthState = {
enabled: boolean;
@@ -210,7 +211,8 @@ export function useAuth(): AuthContextValue {
export function AuthSidebarPanel() {
const auth = useAuth();
const [activityState, setActivityState] = useState<{
- governorActive: boolean;
+ governor: boolean;
+ activeGovernor: boolean;
humanNodeActive: boolean;
} | null>(null);
@@ -227,12 +229,17 @@ export function AuthSidebarPanel() {
const profile = await apiHuman(address);
if (!active) return;
setActivityState({
- governorActive: profile.governorActive,
+ governor: profile.governor,
+ activeGovernor: profile.governorActive,
humanNodeActive: profile.humanNodeActive,
});
} catch {
if (!active) return;
- setActivityState({ governorActive: false, humanNodeActive: false });
+ setActivityState({
+ governor: false,
+ activeGovernor: false,
+ humanNodeActive: false,
+ });
}
};
@@ -255,9 +262,15 @@ export function AuthSidebarPanel() {
const humanNodeActive = Boolean(
auth.authenticated && activityState?.humanNodeActive,
);
- const governorActive = Boolean(
- auth.authenticated && activityState?.governorActive,
+ const governor = Boolean(auth.authenticated && activityState?.governor);
+ const activeGovernor = Boolean(
+ auth.authenticated && activityState?.activeGovernor,
);
+ const identityStatuses = governanceIdentityStatuses({
+ governor,
+ activeGovernor,
+ humanNode: humanNodeActive,
+ });
const gateError =
auth.authenticated && !auth.eligible
@@ -274,32 +287,36 @@ export function AuthSidebarPanel() {
Wallet
{addressLabel}
-
- Human node
-
- {humanNodeActive ? "Active" : "Not active"}
-
-
-
- Governor
-
- {governorActive ? "Active" : "Not active"}
-
-
+ {(
+ [
+ ["humanNode", auth.gateReason],
+ [
+ "governor",
+ "Governor status is earned through the Vortex tier system.",
+ ],
+ [
+ "activeGovernor",
+ "Active Governor status reflects completed governing thresholds for the current era.",
+ ],
+ ] as const
+ ).map(([key, title]) => {
+ const status = identityStatuses[key];
+ return (
+
+ {status.label}
+
+ {status.value}
+
+
+ );
+ })}
{auth.lastError ? (
diff --git a/src/components/GovernanceStatusPills.tsx b/src/components/GovernanceStatusPills.tsx
new file mode 100644
index 0000000..2c7fdc6
--- /dev/null
+++ b/src/components/GovernanceStatusPills.tsx
@@ -0,0 +1,19 @@
+import { governanceIdentityStatuses } from "@/lib/humanNodesUi";
+import { StatusPill } from "./StatusPill";
+
+type GovernanceStatusPillsProps = {
+ governor: boolean;
+ activeGovernor: boolean;
+ humanNode: boolean;
+};
+
+export function GovernanceStatusPills(props: GovernanceStatusPillsProps) {
+ const statuses = governanceIdentityStatuses(props);
+ return (
+
+ {Object.values(statuses).map((status) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx
index 89fc078..e132c2b 100644
--- a/src/components/ProposalPageHeader.tsx
+++ b/src/components/ProposalPageHeader.tsx
@@ -61,6 +61,7 @@ export function ProposalPageHeader({
if (!proposalId) return stageLinks;
return buildProposalStageLinks({
canonicalRoute: status?.canonicalRoute,
+ draftRoute: status?.draftHistory?.route ?? stageLinks?.draft,
liveStage,
proposalId,
routeOverrides: stageLinks,
@@ -72,6 +73,7 @@ export function ProposalPageHeader({
showFormationStage,
stageLinks,
status?.canonicalRoute,
+ status?.draftHistory?.route,
]);
return (
diff --git a/src/components/ProposalStageBar.tsx b/src/components/ProposalStageBar.tsx
index ffc5730..7061e9f 100644
--- a/src/components/ProposalStageBar.tsx
+++ b/src/components/ProposalStageBar.tsx
@@ -55,6 +55,7 @@ function withSnapshotStage(href: string, stage: ProposalStage): string {
type BuildProposalStageLinksInput = {
canonicalRoute?: string;
+ draftRoute?: string;
liveStage: ProposalStage;
proposalId: string;
routeOverrides?: Partial
>;
@@ -63,6 +64,7 @@ type BuildProposalStageLinksInput = {
export function buildProposalStageLinks({
canonicalRoute,
+ draftRoute,
liveStage,
proposalId,
routeOverrides,
@@ -71,6 +73,8 @@ export function buildProposalStageLinks({
const liveIndex = stageProgressIndex(liveStage);
const links: Partial> = {};
+ if (draftRoute) links.draft = draftRoute;
+
for (const stage of stageOrder) {
if (stage === "draft") continue;
if (stage === "build" && !showFormationStage && liveStage !== "build") {
diff --git a/src/components/StageChip.css b/src/components/StageChip.css
index d399cb6..2e0b7dd 100644
--- a/src/components/StageChip.css
+++ b/src/components/StageChip.css
@@ -15,6 +15,12 @@
white-space: nowrap;
}
+.stage-chip--draft {
+ --stage-chip-bg: var(--control-glass-bg);
+ --stage-chip-border: var(--surface-glass-border);
+ --stage-chip-text: var(--text);
+}
+
.stage-chip--proposal-pool {
--stage-chip-bg: #fff1dc;
--stage-chip-border: rgba(176, 102, 25, 0.2);
diff --git a/src/components/StageChip.tsx b/src/components/StageChip.tsx
index 08b06f5..f52b2f6 100644
--- a/src/components/StageChip.tsx
+++ b/src/components/StageChip.tsx
@@ -12,6 +12,7 @@ import {
import "./StageChip.css";
const chipClasses: Record = {
+ draft: "stage-chip--draft",
proposal_pool: "stage-chip--proposal-pool",
chamber_vote: "stage-chip--chamber-vote",
citizen_veto: "stage-chip--citizen-veto",
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index 2f26c32..0df378e 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -23,10 +23,13 @@ import type {
GetInvisionResponse,
GetMyGovernanceResponse,
GetProposalDraftsResponse,
+ GetPublicProposalDraftsResponse,
GetProposalsResponse,
GetProposalTimelineResponse,
HumanNodeProfileDto,
ProposalDraftDetailDto,
+ PublicProposalDraftKindDto,
+ PublicProposalDraftSortDto,
ProposalThreadDetailDto,
ProposalThreadDto,
ProposalThreadListDto,
@@ -800,6 +803,39 @@ export async function apiProposalDraft(
return await apiGet(`/api/proposals/drafts/${id}`);
}
+export async function apiPublicProposalDrafts(input?: {
+ q?: string;
+ chamber?: string;
+ author?: string;
+ initiative?: string;
+ proposalPath?: PublicProposalDraftKindDto;
+ sort?: PublicProposalDraftSortDto;
+ cursor?: string;
+ limit?: number;
+}): Promise {
+ const params = new URLSearchParams();
+ if (input?.q) params.set("q", input.q);
+ if (input?.chamber) params.set("chamber", input.chamber);
+ if (input?.author) params.set("author", input.author);
+ if (input?.initiative) params.set("initiative", input.initiative);
+ if (input?.proposalPath) params.set("proposalPath", input.proposalPath);
+ if (input?.sort) params.set("sort", input.sort);
+ if (input?.cursor) params.set("cursor", input.cursor);
+ if (input?.limit) params.set("limit", String(input.limit));
+ const qs = params.toString();
+ return await apiGet(
+ `/api/proposals/public-drafts?${qs}`,
+ );
+}
+
+export async function apiPublicProposalDraft(
+ id: string,
+): Promise {
+ return await apiGet(
+ `/api/proposals/public-drafts/${encodeURIComponent(id)}`,
+ );
+}
+
export type ProposalDraftFormPayload = {
templateId?: "project" | "system";
presetId?: string;
@@ -883,6 +919,42 @@ export async function apiProposalDraftDelete(input: {
});
}
+export async function apiProposalDraftPublish(input: {
+ draftId: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "proposal.draft.publish";
+ draftId: string;
+ revision: number;
+ publicUrl: string;
+ publishedAt: string;
+ updatedAt: string;
+}> {
+ return await apiCommand({
+ type: "proposal.draft.publish",
+ payload: { draftId: input.draftId },
+ idempotencyKey: input.idempotencyKey,
+ });
+}
+
+export async function apiProposalDraftUnpublish(input: {
+ draftId: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "proposal.draft.unpublish";
+ draftId: string;
+ unpublished: boolean;
+ updatedAt: string;
+}> {
+ return await apiCommand({
+ type: "proposal.draft.unpublish",
+ payload: { draftId: input.draftId },
+ idempotencyKey: input.idempotencyKey,
+ });
+}
+
export async function apiProposalSubmitToPool(input: {
draftId: string;
idempotencyKey?: string;
diff --git a/src/lib/humanNodesUi.ts b/src/lib/humanNodesUi.ts
index 6b1f6c2..2d85eb5 100644
--- a/src/lib/humanNodesUi.ts
+++ b/src/lib/humanNodesUi.ts
@@ -15,7 +15,12 @@ export type HumanNodesTierFilter =
| "legate"
| "consul"
| "citizen";
-export type HumanNodesStatusFilter = "all" | "governor" | "human" | "inactive";
+export type HumanNodesStatusFilter =
+ | "all"
+ | "governor"
+ | "active-governor"
+ | "human"
+ | "inactive";
export type HumanNodesCmRange = "all" | "0-50" | "50-200" | "200+";
export type HumanNodesFilters = {
@@ -25,6 +30,32 @@ export type HumanNodesFilters = {
tierFilter: HumanNodesTierFilter;
};
+export type GovernanceIdentityState = {
+ governor: boolean;
+ activeGovernor: boolean;
+ humanNode: boolean;
+};
+
+export function governanceIdentityStatuses(state: GovernanceIdentityState) {
+ return {
+ governor: {
+ label: "Governor",
+ value: state.governor ? "Active" : "Not active",
+ active: state.governor,
+ },
+ activeGovernor: {
+ label: "Active governor",
+ value: state.activeGovernor ? "Active" : "Not active",
+ active: state.activeGovernor,
+ },
+ humanNode: {
+ label: "Human node",
+ value: state.humanNode ? "Active" : "Not active",
+ active: state.humanNode,
+ },
+ } as const;
+}
+
export const DEFAULT_HUMAN_NODES_FILTERS: HumanNodesFilters = {
sortBy: "acm-desc",
tierFilter: "all",
@@ -70,10 +101,12 @@ export function filterHumanNodes(input: {
statusFilter === "all"
? true
: statusFilter === "governor"
- ? node.active.governorActive
- : statusFilter === "human"
- ? node.active.humanNodeActive
- : !node.active.governorActive && !node.active.humanNodeActive;
+ ? node.active.governor
+ : statusFilter === "active-governor"
+ ? node.active.governorActive
+ : statusFilter === "human"
+ ? node.active.humanNodeActive
+ : !node.active.governorActive && !node.active.humanNodeActive;
const acmValue = node.cmTotals?.acm ?? node.acm ?? 0;
const matchesRange =
cmRange === "all"
diff --git a/src/pages/feed/hooks/useFeedChamberFilters.ts b/src/pages/feed/hooks/useFeedChamberFilters.ts
index 4a1e7b6..d057104 100644
--- a/src/pages/feed/hooks/useFeedChamberFilters.ts
+++ b/src/pages/feed/hooks/useFeedChamberFilters.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
-import { apiClock, apiHuman, apiMyGovernance } from "@/lib/apiClient";
+import { apiHuman, apiMyGovernance } from "@/lib/apiClient";
import type { FeedScope } from "@/lib/feedScopeRouting";
export function useFeedChamberFilters(input: {
@@ -32,23 +32,17 @@ export function useFeedChamberFilters(input: {
setChambersLoading(true);
(async () => {
try {
- const [governance, profile, clock] = await Promise.all([
+ const [governance, profile] = await Promise.all([
apiMyGovernance(),
apiHuman(address),
- apiClock(),
]);
if (!active) return;
- const tier = profile.tierProgress?.tier?.trim().toLowerCase() ?? "";
- const bootstrapGovernor =
- clock.currentEra === 0 && tier !== "" && tier !== "nominee";
const chamberIds = governance.myChamberIds ?? [];
const unique = Array.from(
new Set(["general", ...chamberIds.map((id) => id.toLowerCase())]),
);
setChamberFilters(unique);
- setViewerGovernorActive(
- Boolean(profile.governorActive) || bootstrapGovernor,
- );
+ setViewerGovernorActive(Boolean(profile.governorActive));
} catch (error) {
if (!active) return;
setChamberFilters([]);
diff --git a/src/pages/human-nodes/HumanNode.tsx b/src/pages/human-nodes/HumanNode.tsx
index 860014c..d279885 100644
--- a/src/pages/human-nodes/HumanNode.tsx
+++ b/src/pages/human-nodes/HumanNode.tsx
@@ -34,10 +34,13 @@ import {
ProfileGovernanceActivitySection,
} from "@/pages/profile/components/ProfileActivityProjectsSection";
import { ProfileTierProgressSection } from "@/pages/profile/components/ProfileTierProgressSection";
+import { PublicDraftsSection } from "@/pages/proposals/draft/PublicDraftsSection";
+import { usePublicDrafts } from "@/pages/proposals/draft/usePublicDrafts";
const HumanNode: React.FC = () => {
const auth = useAuth();
const { id } = useParams();
+ const publicDrafts = usePublicDrafts({ author: id });
const [activityFilter, setActivityFilter] = useState("all");
const [copied, setCopied] = useState(false);
const {
@@ -86,6 +89,7 @@ const HumanNode: React.FC = () => {
}
const {
+ governor,
governorActive,
humanNodeActive,
heroStats,
@@ -159,6 +163,7 @@ const HumanNode: React.FC = () => {
{
visibleDetails={visibleDetails}
/>
+
+
{
label: "Status",
options: [
{ value: "all", label: "All statuses" },
- { value: "governor", label: "Governor active" },
+ { value: "governor", label: "Governor" },
+ { value: "active-governor", label: "Active governor" },
{ value: "human", label: "Human node active" },
{ value: "inactive", label: "Inactive" },
],
diff --git a/src/pages/human-nodes/components/HumanNodeHero.tsx b/src/pages/human-nodes/components/HumanNodeHero.tsx
index f1379fa..e637836 100644
--- a/src/pages/human-nodes/components/HumanNodeHero.tsx
+++ b/src/pages/human-nodes/components/HumanNodeHero.tsx
@@ -2,7 +2,7 @@ import { Badge } from "@/components/primitives/badge";
import { HintLabel } from "@/components/Hint";
import { Surface } from "@/components/Surface";
import { AvatarPlaceholder } from "@/components/AvatarPlaceholder";
-import { StatusPill } from "@/components/StatusPill";
+import { GovernanceStatusPills } from "@/components/GovernanceStatusPills";
import { Kicker } from "@/components/Kicker";
import type { HumanNodeProfileDto } from "@/types/api";
import { Check, Copy } from "lucide-react";
@@ -11,6 +11,7 @@ type HumanNodeHeroProps = {
copied: boolean;
headerTitle: string;
humanNodeActive: boolean;
+ governor: boolean;
governorActive: boolean;
onCopyAddress: () => void;
profile: HumanNodeProfileDto;
@@ -23,6 +24,7 @@ export function HumanNodeHero({
copied,
headerTitle,
humanNodeActive,
+ governor,
governorActive,
onCopyAddress,
profile,
@@ -69,18 +71,11 @@ export function HumanNodeHero({
-
-
-
-
+
diff --git a/src/pages/human-nodes/components/HumanNodesResultsCard.tsx b/src/pages/human-nodes/components/HumanNodesResultsCard.tsx
index 33319a4..48c198f 100644
--- a/src/pages/human-nodes/components/HumanNodesResultsCard.tsx
+++ b/src/pages/human-nodes/components/HumanNodesResultsCard.tsx
@@ -121,6 +121,10 @@ export function HumanNodesResultsCard({
{ label: "Faction", value: factionName },
{
label: "Governor",
+ value: node.active.governor ? "Yes" : "No",
+ },
+ {
+ label: "Active governor",
value: node.active.governorActive ? "Active" : "Not active",
},
{
diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx
index 90fc0b1..19bfb9a 100644
--- a/src/pages/initiatives/Initiative.tsx
+++ b/src/pages/initiatives/Initiative.tsx
@@ -39,12 +39,15 @@ import { InitiativeProposalsSection } from "./components/InitiativeProposalsSect
import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection";
import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection";
import { useInitiativePageData } from "./hooks/useInitiativePageData";
+import { PublicDraftsSection } from "@/pages/proposals/draft/PublicDraftsSection";
+import { usePublicDrafts } from "@/pages/proposals/draft/usePublicDrafts";
const Initiative: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const auth = useAuth();
const { initiative, loadError, reload } = useInitiativePageData(id);
+ const publicDrafts = usePublicDrafts({ initiative: id });
const [editing, setEditing] = useState(false);
const { actionError, mutating, runAction } = useActionRunner({ reload });
@@ -245,6 +248,7 @@ const Initiative: React.FC = () => {
threads={threads}
/>
+
= ({ showHint = true }) => {
const [activityFilter, setActivityFilter] = useState("all");
const [profile, setProfile] = useState(null);
const [loadError, setLoadError] = useState(null);
+ const publicDrafts = usePublicDrafts({ author: auth.address ?? undefined });
useEffect(() => {
if (auth.enabled && auth.loading) {
@@ -191,6 +194,8 @@ const Profile: React.FC = ({ showHint = true }) => {
visibleDetails={visibleDetails}
/>
+
+
-
-
-
-
+
diff --git a/src/pages/proposals/ProposalCreation.tsx b/src/pages/proposals/ProposalCreation.tsx
index 69fb686..d33b754 100644
--- a/src/pages/proposals/ProposalCreation.tsx
+++ b/src/pages/proposals/ProposalCreation.tsx
@@ -4,11 +4,16 @@ import { useNavigate, useSearchParams } from "react-router";
import { useAuth } from "@/app/auth/AuthContext";
import { PageHint } from "@/components/PageHint";
import { SIM_AUTH_ENABLED } from "@/lib/featureFlags";
-import { apiProposalSubmitToPool } from "@/lib/apiClient";
+import {
+ apiProposalDraftPublish,
+ apiProposalSubmitToPool,
+ type ApiError,
+} from "@/lib/apiClient";
import { toTimestampMs } from "@/lib/dateTime";
import { initiativeOptionsWithSelection } from "@/lib/initiativeUi";
import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors";
import { usePrefersReducedMotion } from "@/lib/usePrefersReducedMotion";
+import type { DraftPublicationSummaryDto } from "@/types/api";
import {
ProposalCreationLineageMessage,
ProposalCreationMessages,
@@ -31,6 +36,8 @@ import {
type ProposalWizardSessionV2,
} from "./proposalCreation/sessionStorage";
import { proposalSubmitErrorStep } from "./proposalCreation/submitErrorRouting";
+import { isProposalDraftPublicationReady } from "./proposalCreation/publicationReadiness";
+import { proposalDraftRoutes } from "./draft/draftUi";
import { BudgetStep } from "./proposalCreation/steps/BudgetStep";
import { IntentStep } from "./proposalCreation/steps/IntentStep";
import { PlanStep } from "./proposalCreation/steps/PlanStep";
@@ -133,11 +140,19 @@ const ProposalCreation: React.FC = () => {
);
const [saveError, setSaveError] = useState(null);
const [submitError, setSubmitError] = useState(null);
+ const [publication, setPublication] = useState({
+ status: "private",
+ });
+ const [publishing, setPublishing] = useState(false);
const [recoverableSessions, setRecoverableSessions] = useState(() =>
repository.listRecoverable(session.sessionId),
);
const headingRef = useRef(null);
const submitInFlight = useRef(false);
+ const submitIdempotencyKeyRef = useRef(null);
+ const submitRetryDraftIdRef = useRef(null);
+ const submitRetryDirectRef = useRef(false);
+ const publishIdempotencyKeyRef = useRef(null);
const observedQueryRef = useRef(searchParams.toString());
const pendingInternalQueryRef = useRef(null);
@@ -167,9 +182,10 @@ const ProposalCreation: React.FC = () => {
tierProgress,
});
+ const publicationReady = isProposalDraftPublicationReady(draft, templateKind);
const wizardContext = useMemo(
- () => ({ draft, presetId, tierBlocked }),
- [draft, presetId, tierBlocked],
+ () => ({ draft, presetId, publicationReady, tierBlocked }),
+ [draft, presetId, publicationReady, tierBlocked],
);
const currentPathId = pathIdForDraft(draft, templateKind);
const currentPath = pathDefinition(currentPathId);
@@ -184,7 +200,6 @@ const ProposalCreation: React.FC = () => {
(step) => validateWizardStep(step.id, wizardContext).valid,
);
const submitDisabled =
- !fullPathValid ||
!fullPathValid ||
!canAct ||
tierBlocked ||
@@ -220,6 +235,13 @@ const ProposalCreation: React.FC = () => {
const textareaClassName =
"w-full rounded-lg border border-[color:var(--surface-glass-border)] bg-[color:var(--control-glass-bg)] px-3 py-2 text-sm text-text shadow-[var(--shadow-control)] transition supports-[backdrop-filter]:backdrop-blur-md hover:border-[color:var(--surface-glass-hover-border)] hover:bg-[color:var(--control-glass-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--primary-dim)] focus-visible:ring-offset-2 focus-visible:ring-offset-panel";
+ useEffect(() => {
+ submitIdempotencyKeyRef.current = null;
+ submitRetryDraftIdRef.current = null;
+ submitRetryDirectRef.current = false;
+ publishIdempotencyKeyRef.current = null;
+ }, [session.sessionId]);
+
const runEffects = useCallback(
(effects: WizardEffect[]) => {
const behavior = prefersReducedMotion ? "auto" : "smooth";
@@ -355,6 +377,8 @@ const ProposalCreation: React.FC = () => {
);
setSaveError(null);
setSubmitError(null);
+ setPublication({ status: "private" });
+ setPublishing(false);
setRecoverableSessions(repository.listRecoverable(nextSession.sessionId));
runEffects([{ type: "focus-step", stepId: resolvedStep }]);
},
@@ -366,6 +390,7 @@ const ProposalCreation: React.FC = () => {
draft: nextDraft,
draftId,
presetId: nextPresetId,
+ publication: nextPublication,
templateKind: nextTemplateKind,
}: ProposalDraftHydrationResult) => {
const nextPathId = pathIdForDraft(nextDraft, nextTemplateKind);
@@ -393,6 +418,7 @@ const ProposalCreation: React.FC = () => {
serverSavedAt: new Date().toISOString(),
});
activateSession(saved, resolvedStep);
+ setPublication(nextPublication);
setSavedAt(Date.now());
},
[activateSession, repository, requestedStep],
@@ -535,16 +561,32 @@ const ProposalCreation: React.FC = () => {
submitInFlight.current = true;
setSubmitError(null);
send({ type: "SUBMIT_REQUESTED" });
+ let commandAttempted = false;
try {
- const draftId = await saveDraftNow();
+ const draftId =
+ submitRetryDirectRef.current && submitRetryDraftIdRef.current
+ ? submitRetryDraftIdRef.current
+ : await saveDraftNow();
if (!draftId) throw new Error("Draft could not be synchronized.");
- const response = await apiProposalSubmitToPool({ draftId });
+ submitRetryDraftIdRef.current = draftId;
+ submitIdempotencyKeyRef.current ??= `proposal-submit-${crypto.randomUUID()}`;
+ commandAttempted = true;
+ const response = await apiProposalSubmitToPool({
+ draftId,
+ idempotencyKey: submitIdempotencyKeyRef.current,
+ });
if (sessionRef.current.sessionId !== submittingSession.sessionId) return;
repository.remove(submittingSession.sessionId);
+ submitIdempotencyKeyRef.current = null;
+ submitRetryDraftIdRef.current = null;
+ submitRetryDirectRef.current = false;
if (submittingSession.legacyRecovery) repository.clearLegacy();
navigate(`/app/proposals/${response.proposalId}/pp`, { replace: true });
} catch (error) {
if (sessionRef.current.sessionId !== submittingSession.sessionId) return;
+ const status = (error as ApiError | null)?.status;
+ submitRetryDirectRef.current =
+ commandAttempted && (typeof status !== "number" || status >= 500);
const message = formatProposalSubmitError(error);
setSubmitError(message);
const targetStep = proposalSubmitErrorStep(
@@ -561,6 +603,54 @@ const ProposalCreation: React.FC = () => {
}
};
+ const publishDraft = async () => {
+ if (
+ !isReview ||
+ !publicationReady ||
+ !canAct ||
+ tierBlocked ||
+ publishing
+ ) {
+ return;
+ }
+ setPublishing(true);
+ setSaveError(null);
+ const publishingSessionId = sessionRef.current.sessionId;
+ try {
+ const draftId = await saveDraftNow();
+ if (!draftId) throw new Error("Draft could not be synchronized.");
+ if (sessionRef.current.sessionId !== publishingSessionId) return;
+ publishIdempotencyKeyRef.current ??= `draft-publish-${crypto.randomUUID()}`;
+ const response = await apiProposalDraftPublish({
+ draftId,
+ idempotencyKey: publishIdempotencyKeyRef.current,
+ });
+ if (sessionRef.current.sessionId !== publishingSessionId) return;
+ setPublication({
+ status: "published",
+ revision: response.revision,
+ publicUrl: response.publicUrl,
+ publishedAt: response.publishedAt,
+ publicUpdatedAt: response.updatedAt,
+ hasUnpublishedChanges: false,
+ });
+ publishIdempotencyKeyRef.current = null;
+ } catch (error) {
+ if (sessionRef.current.sessionId !== publishingSessionId) return;
+ setSaveError(error instanceof Error ? error.message : "Publish failed.");
+ const targetStep = proposalSubmitErrorStep(
+ error,
+ currentPathId,
+ wizardContext,
+ );
+ if (targetStep) send({ type: "STEP_REQUESTED", stepId: targetStep });
+ } finally {
+ if (sessionRef.current.sessionId === publishingSessionId) {
+ setPublishing(false);
+ }
+ }
+ };
+
const handleContinue = () => {
if (isReview) {
void submitProposal();
@@ -604,8 +694,8 @@ const ProposalCreation: React.FC = () => {
if (sessionRef.current.sessionId !== savingSessionId) return;
navigate(
draftId || sessionRef.current.draftId
- ? "/app/proposals/drafts"
- : "/app/proposals",
+ ? proposalDraftRoutes.mine
+ : proposalDraftRoutes.proposals,
);
};
@@ -768,7 +858,20 @@ const ProposalCreation: React.FC = () => {
}
onBack={() => send({ type: "BACK_REQUESTED" })}
onContinue={handleContinue}
- submitting={submitting}
+ onSecondaryAction={isReview ? () => void publishDraft() : undefined}
+ secondaryActionDisabled={
+ !publicationReady || !canAct || tierBlocked || publishing
+ }
+ secondaryActionLabel={
+ isReview
+ ? publishing
+ ? "Publishing"
+ : publication.status === "published"
+ ? "Update public draft"
+ : "Publish draft"
+ : undefined
+ }
+ submitting={submitting || publishing}
/>
diff --git a/src/pages/proposals/ProposalDraft.tsx b/src/pages/proposals/ProposalDraft.tsx
index 5de10c9..e307a6a 100644
--- a/src/pages/proposals/ProposalDraft.tsx
+++ b/src/pages/proposals/ProposalDraft.tsx
@@ -5,7 +5,6 @@ import { Button } from "@/components/primitives/button";
import { Card } from "@/components/primitives/card";
import { PageHint } from "@/components/PageHint";
import { useAuth } from "@/app/auth/AuthContext";
-import { parseRatioPair } from "@/lib/dtoParsers";
import {
apiProposalDraft,
apiProposalDraftDelete,
@@ -14,6 +13,8 @@ import {
import { formatLoadError } from "@/lib/errorFormatting";
import type { ProposalDraftDetailDto } from "@/types/api";
import { ProposalDraftDetailsCard } from "./draft/ProposalDraftDetailsCard";
+import { DraftPublicationActions } from "./draft/DraftPublicationActions";
+import { editDraftRoute, proposalDraftRoutes } from "./draft/draftUi";
const ProposalDraft: React.FC = () => {
const auth = useAuth();
@@ -24,10 +25,6 @@ const ProposalDraft: React.FC = () => {
const [loadError, setLoadError] = useState(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(null);
- const { left: filledSlots, right: totalSlots } = parseRatioPair(
- draftDetails?.teamSlots ?? "0 / 0",
- );
- const openSlots = Math.max((totalSlots || 0) - (filledSlots || 0), 0);
const submittedDraft = Boolean(draftDetails?.submittedProposalId);
useEffect(() => {
@@ -65,19 +62,17 @@ const ProposalDraft: React.FC = () => {
{id ? (
) : null}
@@ -101,16 +96,14 @@ const ProposalDraft: React.FC = () => {
{id && !submittedDraft ? (
<>
>
) : null}
+ {id ? (
+
+ setDraftDetails((current) =>
+ current ? { ...current, publication } : current,
+ )
+ }
+ />
+ ) : null}
{draftDetails.submittedProposalId ? (
);
};
diff --git a/src/pages/proposals/ProposalDrafts.tsx b/src/pages/proposals/ProposalDrafts.tsx
index c2b0f39..ced07d7 100644
--- a/src/pages/proposals/ProposalDrafts.tsx
+++ b/src/pages/proposals/ProposalDrafts.tsx
@@ -1,23 +1,20 @@
import { useEffect, useMemo, useState } from "react";
-import { Link, useNavigate } from "react-router";
+import { Link } from "react-router";
import { Card } from "@/components/primitives/card";
import { Button } from "@/components/primitives/button";
-import { Badge } from "@/components/primitives/badge";
import { SearchBar } from "@/components/SearchBar";
import { PageHint } from "@/components/PageHint";
import { SectionHeader } from "@/components/SectionHeader";
-import { Kicker } from "@/components/Kicker";
import { NoDataYetBar } from "@/components/NoDataYetBar";
import { apiProposalDrafts } from "@/lib/apiClient";
-import { formatDateTime } from "@/lib/dateTime";
import { formatLoadError } from "@/lib/errorFormatting";
-import { proposalSummaryPreview } from "@/lib/textPreview";
import type { ProposalDraftListItemDto } from "@/types/api";
import { useAuth } from "@/app/auth/AuthContext";
+import { OwnerDraftCard } from "./draft/OwnerDraftCard";
+import { proposalDraftRoutes } from "./draft/draftUi";
const ProposalDrafts: React.FC = () => {
const auth = useAuth();
- const navigate = useNavigate();
const [drafts, setDrafts] = useState
(null);
const [loadError, setLoadError] = useState(null);
const [query, setQuery] = useState("");
@@ -81,11 +78,11 @@ const ProposalDrafts: React.FC = () => {
@@ -137,40 +134,24 @@ const ProposalDrafts: React.FC = () => {
{drafts !== null && drafts.length === 0 && !loadError ? (
) : null}
+ {drafts !== null && drafts.length > 0 && filtered.length === 0 ? (
+
+ ) : null}
-
+
{filtered.map((draft) => (
-
-
-
-
Draft · {formatDateTime(draft.updated)}
-
- {draft.title}
-
-
- {proposalSummaryPreview(draft.summary)}
-
-
-
{draft.chamber}
-
-
-
Tier: {draft.tier}
-
-
-
-
-
-
+
+ setDrafts(
+ (current) =>
+ current?.map((item) =>
+ item.id === draft.id ? { ...item, publication } : item,
+ ) ?? null,
+ )
+ }
+ />
))}
diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx
index 75c5981..534107e 100644
--- a/src/pages/proposals/ProposalFinished.tsx
+++ b/src/pages/proposals/ProposalFinished.tsx
@@ -3,6 +3,7 @@ import { Button } from "@/components/primitives/button";
import { SectionHeader } from "@/components/SectionHeader";
import { Surface } from "@/components/Surface";
import { ProposalPageHeader } from "@/components/ProposalPageHeader";
+import { editDraftRoute, reconsiderProposalRoute } from "./draft/draftUi";
import { apiProposalFinishedPage } from "@/lib/apiClient";
import {
useProposalStageSync,
@@ -61,8 +62,8 @@ const ProposalFinished: React.FC = () => {
Resubmit for reconsideration
diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx
index b31722d..1225dac 100644
--- a/src/pages/proposals/Proposals.tsx
+++ b/src/pages/proposals/Proposals.tsx
@@ -19,6 +19,7 @@ import { apiProposals } from "@/lib/apiClient";
import type { ProposalListItemDto } from "@/types/api";
import { ProposalListCard } from "./list/ProposalListCard";
import { useProposalStageDetails } from "./list/useProposalStageDetails";
+import { proposalDraftRoutes } from "./draft/draftUi";
const Proposals: React.FC = () => {
const [proposalData, setProposalData] = useState<
@@ -71,17 +72,27 @@ const Proposals: React.FC = () => {
-
+
+
+
+
diff --git a/src/pages/proposals/PublicDraft.tsx b/src/pages/proposals/PublicDraft.tsx
new file mode 100644
index 0000000..7d0de8e
--- /dev/null
+++ b/src/pages/proposals/PublicDraft.tsx
@@ -0,0 +1,125 @@
+import { useEffect, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router";
+
+import { useAuth } from "@/app/auth/AuthContext";
+import { PageHint } from "@/components/PageHint";
+import { Button } from "@/components/primitives/button";
+import { Surface } from "@/components/Surface";
+import { addressesReferToSameIdentity } from "@/lib/addressIdentity";
+import { apiProposalStatus, apiPublicProposalDraft } from "@/lib/apiClient";
+import { formatLoadError } from "@/lib/errorFormatting";
+import type { ProposalDraftDetailDto } from "@/types/api";
+import { DraftPublicationActions } from "./draft/DraftPublicationActions";
+import { CopyLinkButton } from "./draft/CopyLinkButton";
+import {
+ editDraftRoute,
+ proposalDraftRoutes,
+ publicationRoute,
+} from "./draft/draftUi";
+import { ProposalDraftDetailsCard } from "./draft/ProposalDraftDetailsCard";
+
+const PublicDraft: React.FC = () => {
+ const auth = useAuth();
+ const navigate = useNavigate();
+ const { id = "" } = useParams();
+ const [draft, setDraft] = useState
(null);
+ const [loadError, setLoadError] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ void apiPublicProposalDraft(id)
+ .then((response) => {
+ if (!active) return;
+ setDraft(response);
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (!active) return;
+ setDraft(null);
+ setLoadError(formatLoadError(error, "Public draft unavailable."));
+ });
+ return () => {
+ active = false;
+ };
+ }, [id]);
+
+ if (!draft) {
+ return (
+
+
+
+
+ {loadError ?? "Loading public draft…"}
+
+
+ );
+ }
+
+ const owner = addressesReferToSameIdentity(auth.address, draft.proposer);
+
+ return (
+
+
+
+
+
+ {!owner && draft.id ? (
+
+ ) : null}
+ {owner && draft.id && draft.publication.status === "published" ? (
+
+ ) : null}
+ {owner && draft.id ? (
+ {
+ if (publication.status === "withdrawn") {
+ navigate(proposalDraftRoutes.public, { replace: true });
+ return;
+ }
+ setDraft((current) =>
+ current ? { ...current, publication } : current,
+ );
+ }}
+ />
+ ) : null}
+ {draft.submittedProposalId ? (
+
+ ) : null}
+
+
+
+
+
+ );
+};
+
+export default PublicDraft;
diff --git a/src/pages/proposals/PublicDrafts.tsx b/src/pages/proposals/PublicDrafts.tsx
new file mode 100644
index 0000000..dacb93a
--- /dev/null
+++ b/src/pages/proposals/PublicDrafts.tsx
@@ -0,0 +1,212 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router";
+
+import { NoDataYetBar } from "@/components/NoDataYetBar";
+import { PageHint } from "@/components/PageHint";
+import { Button } from "@/components/primitives/button";
+import { SearchBar } from "@/components/SearchBar";
+import { SectionHeader } from "@/components/SectionHeader";
+import { Surface } from "@/components/Surface";
+import { apiPublicProposalDrafts } from "@/lib/apiClient";
+import { formatLoadError } from "@/lib/errorFormatting";
+import type { PublicProposalDraftListItemDto } from "@/types/api";
+import { PublicDraftCard } from "./draft/PublicDraftCard";
+import { proposalDraftRoutes, publicDraftKindOptions } from "./draft/draftUi";
+import type {
+ PublicProposalDraftKindDto,
+ PublicProposalDraftSortDto,
+} from "@/types/api";
+
+type DirectoryFilters = {
+ proposalPath: "" | PublicProposalDraftKindDto;
+ sort: PublicProposalDraftSortDto;
+};
+
+const initialFilters: DirectoryFilters = {
+ proposalPath: "",
+ sort: "updated",
+};
+
+const publicDraftSortOptions = [
+ { value: "updated", label: "Recently updated" },
+ { value: "published", label: "Recently published" },
+] satisfies Array<{ value: PublicProposalDraftSortDto; label: string }>;
+
+const PUBLIC_DRAFT_PAGE_SIZE = 20;
+
+const PublicDrafts: React.FC = () => {
+ const [items, setItems] = useState(
+ null,
+ );
+ const [loadError, setLoadError] = useState(null);
+ const [search, setSearch] = useState("");
+ const [filters, setFilters] = useState(initialFilters);
+ const [appliedFilters, setAppliedFilters] =
+ useState(initialFilters);
+ const [nextCursor, setNextCursor] = useState();
+ const [loadingMore, setLoadingMore] = useState(false);
+ const query = useMemo(
+ () => ({
+ q: search.trim() || undefined,
+ proposalPath: appliedFilters.proposalPath || undefined,
+ sort: appliedFilters.sort,
+ limit: PUBLIC_DRAFT_PAGE_SIZE,
+ }),
+ [appliedFilters.proposalPath, appliedFilters.sort, search],
+ );
+ const queryKey = `${query.q ?? ""}|${query.proposalPath ?? ""}|${query.sort}`;
+ const hasActiveQuery = Boolean(search.trim() || appliedFilters.proposalPath);
+ const activeQueryKey = useRef(queryKey);
+ activeQueryKey.current = queryKey;
+
+ useEffect(() => {
+ let active = true;
+ setNextCursor(undefined);
+ setLoadingMore(false);
+ const timer = window.setTimeout(() => {
+ setItems(null);
+ void apiPublicProposalDrafts(query)
+ .then((response) => {
+ if (!active) return;
+ setItems(response.items);
+ setNextCursor(response.nextCursor);
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (!active) return;
+ setItems([]);
+ setNextCursor(undefined);
+ setLoadError(formatLoadError(error, "Could not load public drafts."));
+ });
+ }, 250);
+ return () => {
+ active = false;
+ window.clearTimeout(timer);
+ };
+ }, [query]);
+
+ const loadMore = async () => {
+ if (!nextCursor || loadingMore) return;
+ const requestedQueryKey = queryKey;
+ setLoadingMore(true);
+ try {
+ const response = await apiPublicProposalDrafts({
+ ...query,
+ cursor: nextCursor,
+ });
+ if (activeQueryKey.current !== requestedQueryKey) return;
+ setItems((current) => [...(current ?? []), ...response.items]);
+ setNextCursor(response.nextCursor);
+ setLoadError(null);
+ } catch (error) {
+ if (activeQueryKey.current !== requestedQueryKey) return;
+ setLoadError(
+ formatLoadError(
+ error instanceof Error ? error.message : String(error),
+ "Could not load more public drafts.",
+ ),
+ );
+ } finally {
+ if (activeQueryKey.current === requestedQueryKey) setLoadingMore(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Public drafts
+ setSearch(event.target.value)}
+ placeholder="Search public drafts…"
+ ariaLabel="Search public drafts"
+ className="max-w-md"
+ filtersConfig={[
+ {
+ key: "proposalPath",
+ label: "Proposal path",
+ options: [
+ { value: "", label: "All paths" },
+ ...publicDraftKindOptions,
+ ],
+ },
+ {
+ key: "sort",
+ label: "Order",
+ options: publicDraftSortOptions,
+ },
+ ]}
+ filtersState={filters}
+ onFiltersChange={setFilters}
+ onApplyFilters={() => setAppliedFilters(filters)}
+ />
+
+
+ {items === null ? (
+
+ Loading public drafts…
+
+ ) : null}
+ {loadError ? (
+
+ {loadError}
+
+ ) : null}
+ {items !== null && items.length === 0 && !loadError ? (
+ hasActiveQuery ? (
+
+ No public drafts match these filters.
+
+ ) : (
+
+ )
+ ) : null}
+
+ {(items ?? []).map((draft) => (
+
+ ))}
+
+ {nextCursor ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+export default PublicDrafts;
diff --git a/src/pages/proposals/draft/CopyLinkButton.tsx b/src/pages/proposals/draft/CopyLinkButton.tsx
new file mode 100644
index 0000000..bf4691b
--- /dev/null
+++ b/src/pages/proposals/draft/CopyLinkButton.tsx
@@ -0,0 +1,46 @@
+import { useEffect, useRef, useState } from "react";
+
+import { Button } from "@/components/primitives/button";
+
+type CopyLinkButtonProps = {
+ value: string;
+ onError?: (message: string) => void;
+};
+
+export function CopyLinkButton({ value, onError }: CopyLinkButtonProps) {
+ const [copied, setCopied] = useState(false);
+ const resetTimer = useRef(null);
+
+ useEffect(() => {
+ setCopied(false);
+ if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
+ return () => {
+ if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
+ };
+ }, [value]);
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(
+ new URL(value, window.location.origin).toString(),
+ );
+ setCopied(true);
+ if (resetTimer.current !== null) window.clearTimeout(resetTimer.current);
+ resetTimer.current = window.setTimeout(() => setCopied(false), 2_000);
+ } catch {
+ onError?.("Could not copy the public draft link.");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/pages/proposals/draft/DraftPublicationActions.tsx b/src/pages/proposals/draft/DraftPublicationActions.tsx
new file mode 100644
index 0000000..5cff966
--- /dev/null
+++ b/src/pages/proposals/draft/DraftPublicationActions.tsx
@@ -0,0 +1,135 @@
+import { Link } from "react-router";
+
+import { Button } from "@/components/primitives/button";
+import { cn } from "@/lib/utils";
+import type { DraftPublicationSummaryDto } from "@/types/api";
+import { CopyLinkButton } from "./CopyLinkButton";
+import { isPublicDraftVisible } from "./draftUi";
+import { useDraftPublicationActions } from "./useDraftPublicationActions";
+import "@/components/StageChip.css";
+
+type DraftPublicationActionsProps = {
+ draftId: string;
+ publication: DraftPublicationSummaryDto;
+ onChanged?: (publication: DraftPublicationSummaryDto) => void;
+ variant?: "actions" | "visibility-toggle";
+};
+
+export function DraftPublicationActions({
+ draftId,
+ publication,
+ onChanged,
+ variant = "actions",
+}: DraftPublicationActionsProps) {
+ const {
+ canPublish,
+ error,
+ pending,
+ publicUrl,
+ publish,
+ reportError,
+ unpublish,
+ } = useDraftPublicationActions({ draftId, publication, onChanged });
+
+ if (variant === "visibility-toggle") {
+ const isPublic = publication.status === "published";
+ const submitted = publication.status === "submitted";
+ const label = submitted
+ ? "Submitted"
+ : pending
+ ? isPublic
+ ? "Making private"
+ : "Publishing"
+ : isPublic
+ ? "Public"
+ : "Private";
+ return (
+
+
+ {publication.hasUnpublishedChanges && isPublic ? (
+
+ Saved edits are private
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+ {canPublish ? (
+
+ ) : null}
+ {isPublicDraftVisible(publication) ? (
+ <>
+
+
+ >
+ ) : null}
+ {publication.status === "published" ? (
+
+ ) : null}
+
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/pages/proposals/draft/OwnerDraftCard.tsx b/src/pages/proposals/draft/OwnerDraftCard.tsx
new file mode 100644
index 0000000..87abdc8
--- /dev/null
+++ b/src/pages/proposals/draft/OwnerDraftCard.tsx
@@ -0,0 +1,68 @@
+import { Link } from "react-router";
+
+import { Chip } from "@/components/Chip";
+import { GlassyCard } from "@/components/GlassyCard";
+import { Button } from "@/components/primitives/button";
+import { formatDateTime } from "@/lib/dateTime";
+import { proposalSummaryPreview } from "@/lib/textPreview";
+import type {
+ DraftPublicationSummaryDto,
+ ProposalDraftListItemDto,
+} from "@/types/api";
+import { DraftPublicationActions } from "./DraftPublicationActions";
+import { editDraftRoute, ownerDraftRoute } from "./draftUi";
+
+type OwnerDraftCardProps = {
+ draft: ProposalDraftListItemDto;
+ onPublicationChanged: (publication: DraftPublicationSummaryDto) => void;
+};
+
+export function OwnerDraftCard({
+ draft,
+ onPublicationChanged,
+}: OwnerDraftCardProps) {
+ const detailUrl = ownerDraftRoute(draft.id);
+ const editUrl = editDraftRoute(draft.id);
+
+ return (
+
+
+
+ {draft.chamber}
+
+
+
+
+
+
+ {draft.title}
+
+
+ {proposalSummaryPreview(draft.summary, 150)}
+
+
+
+
+
+ Updated {formatDateTime(draft.updated)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx b/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx
index d48a5e9..a58b265 100644
--- a/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx
+++ b/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx
@@ -1,195 +1,78 @@
-import { AddressInline } from "@/components/AddressInline";
-import { AttachmentList } from "@/components/AttachmentList";
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/components/primitives/card";
-import { ProposalStageBar } from "@/components/ProposalStageBar";
-import { StatTile } from "@/components/StatTile";
-import { Surface } from "@/components/Surface";
+import { Chip } from "@/components/Chip";
+import { ProposalPageHeader } from "@/components/ProposalPageHeader";
import { TierLabel } from "@/components/TierLabel";
-import { TitledSurface } from "@/components/TitledSurface";
import type { ProposalDraftDetailDto } from "@/types/api";
+import { parseRatioPair } from "@/lib/dtoParsers";
+import { ProposalDetailsSections } from "../shared/ProposalDetailsSections";
type ProposalDraftDetailsCardProps = {
draft: ProposalDraftDetailDto;
- openSlots: number;
};
export const ProposalDraftDetailsCard: React.FC<
ProposalDraftDetailsCardProps
-> = ({ draft, openSlots }) => {
+> = ({ draft }) => {
+ const { left: filledSlots, right: totalSlots } = parseRatioPair(
+ draft.teamSlots,
+ );
+ const openSlots = Math.max(totalSlots - filledSlots, 0);
return (
-
-
-
- {draft.title}
-
-
-
-
-
- }
- radius="2xl"
- className="px-4 py-4"
- labelClassName="text-[0.8rem]"
- valueClassName="text-lg"
- />
- }
- radius="2xl"
- className="px-4 py-4"
- labelClassName="text-[0.8rem]"
- valueClassName="text-lg"
- />
-
-
-
-
- {[
- { label: "Budget ask", value: draft.budget },
- {
- label: "Formation",
- value: draft.formationEligible ? "Yes" : "No",
- },
- {
- label: "Team slots",
- value: `${draft.teamSlots} (open: ${openSlots})`,
- },
- {
- label: "Milestones",
- value: draft.milestonesPlanned,
- },
- ].map((item) => (
-
- ))}
-
-
-
-
-
Summary
-
- {draft.summary}
-
-
-
-
- {draft.rationale}
-
-
-
-
- {draft.checklist.map((item) => (
- - {item}
- ))}
-
-
-
- {draft.budgetScope}
-
-
-
-
-
- Team (locked)
-
- {draft.teamLocked.map((member) => (
-
- {member.name}
- {member.role}
-
- ))}
-
-
-
- Open slots (positions)
-
- {draft.openSlotNeeds.map((slot) => (
-
- {slot.title}
- {slot.desc}
-
- ))}
-
-
+
+
+
+
+ {draft.publication.status === "submitted"
+ ? "Draft history"
+ : "Draft"}
+
+ {draft.publication.revision ? (
+ Public revision {draft.publication.revision}
+ ) : null}
+
+
+
+
-
- Milestones
-
- {draft.milestonesDetail.map((milestone) => (
-
- {milestone.title}
- {milestone.desc}
-
- ))}
-
-
-
- ({
- id: file.title,
- title: file.title,
- href: file.href,
- }))}
- />
-
-
+ ({
+ id: `draft-attachment-${index + 1}`,
+ title: attachment.title,
+ href: attachment.href,
+ }))}
+ authoring={draft.authoring}
+ budgetScope={draft.budgetScope}
+ executionPlan={draft.executionPlan}
+ milestonesDetail={
+ draft.formationEligible ? draft.milestonesDetail : undefined
+ }
+ openSlots={draft.formationEligible ? draft.openSlotNeeds : undefined}
+ overview={draft.overview}
+ showBudgetScope={draft.formationEligible}
+ showExecutionPlan
+ stats={[
+ { label: "Budget ask", value: draft.budget },
+ { label: "Formation", value: draft.formationEligible ? "Yes" : "No" },
+ ...(draft.formationEligible
+ ? [
+ {
+ label: "Team slots",
+ value: `${draft.teamSlots} (${openSlots} available)`,
+ },
+ { label: "Milestones", value: draft.milestonesPlanned },
+ ]
+ : []),
+ ]}
+ summary={draft.summary}
+ teamLocked={draft.formationEligible ? draft.teamLocked : undefined}
+ />
+
);
};
diff --git a/src/pages/proposals/draft/PublicDraftCard.tsx b/src/pages/proposals/draft/PublicDraftCard.tsx
new file mode 100644
index 0000000..500787a
--- /dev/null
+++ b/src/pages/proposals/draft/PublicDraftCard.tsx
@@ -0,0 +1,56 @@
+import { useState } from "react";
+import { Link } from "react-router";
+
+import { AddressInline } from "@/components/AddressInline";
+import { GlassyRecordCard } from "@/components/GlassyRecordCard";
+import { Button } from "@/components/primitives/button";
+import { StatTile } from "@/components/StatTile";
+import { formatDateTime } from "@/lib/dateTime";
+import type { PublicProposalDraftListItemDto } from "@/types/api";
+import { publicDraftKindLabels, publicDraftRoute } from "./draftUi";
+
+type PublicDraftCardProps = {
+ draft: PublicProposalDraftListItemDto;
+};
+
+export function PublicDraftCard({ draft }: PublicDraftCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ return (
+
setExpanded((current) => !current)}
+ stage="draft"
+ summary={draft.summary}
+ title={draft.title}
+ >
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/proposals/draft/PublicDraftsSection.tsx b/src/pages/proposals/draft/PublicDraftsSection.tsx
new file mode 100644
index 0000000..101596e
--- /dev/null
+++ b/src/pages/proposals/draft/PublicDraftsSection.tsx
@@ -0,0 +1,24 @@
+import { GlassySection } from "@/components/GlassySection";
+import type { PublicProposalDraftListItemDto } from "@/types/api";
+import { PublicDraftCard } from "./PublicDraftCard";
+
+type PublicDraftsSectionProps = {
+ drafts: PublicProposalDraftListItemDto[];
+ title?: string;
+};
+
+export function PublicDraftsSection({
+ drafts,
+ title = "Public drafts",
+}: PublicDraftsSectionProps) {
+ if (drafts.length === 0) return null;
+ return (
+
+
+ {drafts.map((draft) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/proposals/draft/draftUi.ts b/src/pages/proposals/draft/draftUi.ts
new file mode 100644
index 0000000..96ada67
--- /dev/null
+++ b/src/pages/proposals/draft/draftUi.ts
@@ -0,0 +1,66 @@
+import type {
+ DraftPublicationSummaryDto,
+ PublicProposalDraftKindDto,
+} from "@/types/api";
+
+export const proposalDraftRoutes = {
+ create: "/app/proposals/new",
+ mine: "/app/proposals/drafts",
+ proposals: "/app/proposals",
+ public: "/app/proposals/public-drafts",
+} as const;
+
+export function ownerDraftRoute(draftId: string): string {
+ return `${proposalDraftRoutes.mine}/${encodeURIComponent(draftId)}`;
+}
+
+export function editDraftRoute(draftId: string): string {
+ return `${proposalDraftRoutes.create}?draftId=${encodeURIComponent(draftId)}`;
+}
+
+export function reconsiderProposalRoute(proposalId: string): string {
+ return `${proposalDraftRoutes.create}?resubmitsProposalId=${encodeURIComponent(proposalId)}`;
+}
+
+export function publicDraftRoute(draftId: string): string {
+ return `${proposalDraftRoutes.public}/${encodeURIComponent(draftId)}`;
+}
+
+export function publicationRoute(
+ draftId: string,
+ publication: DraftPublicationSummaryDto,
+): string {
+ return publication.publicUrl ?? publicDraftRoute(draftId);
+}
+
+export function canPublishDraft(
+ publication: DraftPublicationSummaryDto,
+): boolean {
+ return (
+ publication.status === "private" ||
+ publication.status === "withdrawn" ||
+ (publication.status === "published" &&
+ Boolean(publication.hasUnpublishedChanges))
+ );
+}
+
+export function isPublicDraftVisible(
+ publication: DraftPublicationSummaryDto,
+): boolean {
+ return (
+ publication.status === "published" || publication.status === "submitted"
+ );
+}
+
+export const publicDraftKindLabels: Record
=
+ {
+ policy: "Policy",
+ formation: "Formation",
+ system: "System change",
+ };
+
+export const publicDraftKindOptions = (
+ Object.entries(publicDraftKindLabels) as Array<
+ [PublicProposalDraftKindDto, string]
+ >
+).map(([value, label]) => ({ value, label }));
diff --git a/src/pages/proposals/draft/useDraftPublicationActions.ts b/src/pages/proposals/draft/useDraftPublicationActions.ts
new file mode 100644
index 0000000..a52cf36
--- /dev/null
+++ b/src/pages/proposals/draft/useDraftPublicationActions.ts
@@ -0,0 +1,107 @@
+import { useEffect, useRef, useState } from "react";
+
+import {
+ apiProposalDraftPublish,
+ apiProposalDraftUnpublish,
+} from "@/lib/apiClient";
+import { formatLoadError } from "@/lib/errorFormatting";
+import type { DraftPublicationSummaryDto } from "@/types/api";
+import { canPublishDraft, publicationRoute } from "./draftUi";
+
+type PublicationAction = "publish" | "unpublish";
+
+type UseDraftPublicationActionsInput = {
+ draftId: string;
+ publication: DraftPublicationSummaryDto;
+ onChanged?: (publication: DraftPublicationSummaryDto) => void;
+};
+
+export function useDraftPublicationActions({
+ draftId,
+ publication,
+ onChanged,
+}: UseDraftPublicationActionsInput) {
+ const [pending, setPending] = useState(null);
+ const [error, setError] = useState(null);
+ const currentDraftId = useRef(draftId);
+ const actionKeys = useRef>>({});
+ currentDraftId.current = draftId;
+
+ useEffect(() => {
+ setPending(null);
+ setError(null);
+ actionKeys.current = {};
+ }, [draftId]);
+
+ const publish = async () => {
+ if (pending) return;
+ setPending("publish");
+ setError(null);
+ try {
+ actionKeys.current.publish ??= `draft-publish-${crypto.randomUUID()}`;
+ const response = await apiProposalDraftPublish({
+ draftId,
+ idempotencyKey: actionKeys.current.publish,
+ });
+ if (currentDraftId.current !== draftId) return;
+ onChanged?.({
+ status: "published",
+ revision: response.revision,
+ publicUrl: response.publicUrl,
+ publishedAt: response.publishedAt,
+ publicUpdatedAt: response.updatedAt,
+ hasUnpublishedChanges: false,
+ });
+ actionKeys.current.publish = undefined;
+ } catch (nextError) {
+ setError(
+ formatLoadError(
+ nextError instanceof Error ? nextError.message : null,
+ "Could not publish this draft.",
+ ),
+ );
+ } finally {
+ if (currentDraftId.current === draftId) setPending(null);
+ }
+ };
+
+ const unpublish = async () => {
+ if (pending || publication.status !== "published") return;
+ if (!window.confirm("Remove this draft from public view?")) return;
+ setPending("unpublish");
+ setError(null);
+ try {
+ actionKeys.current.unpublish ??= `draft-unpublish-${crypto.randomUUID()}`;
+ await apiProposalDraftUnpublish({
+ draftId,
+ idempotencyKey: actionKeys.current.unpublish,
+ });
+ if (currentDraftId.current !== draftId) return;
+ onChanged?.({
+ ...publication,
+ status: "withdrawn",
+ hasUnpublishedChanges: false,
+ });
+ actionKeys.current.unpublish = undefined;
+ } catch (nextError) {
+ setError(
+ formatLoadError(
+ nextError instanceof Error ? nextError.message : null,
+ "Could not unpublish this draft.",
+ ),
+ );
+ } finally {
+ if (currentDraftId.current === draftId) setPending(null);
+ }
+ };
+
+ return {
+ canPublish: canPublishDraft(publication),
+ error,
+ pending,
+ publicUrl: publicationRoute(draftId, publication),
+ publish,
+ reportError: setError,
+ unpublish,
+ };
+}
diff --git a/src/pages/proposals/draft/usePublicDrafts.ts b/src/pages/proposals/draft/usePublicDrafts.ts
new file mode 100644
index 0000000..8d4b3bf
--- /dev/null
+++ b/src/pages/proposals/draft/usePublicDrafts.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from "react";
+
+import { apiPublicProposalDrafts } from "@/lib/apiClient";
+import type { PublicProposalDraftListItemDto } from "@/types/api";
+
+type PublicDraftFilters = {
+ author?: string;
+ initiative?: string;
+};
+
+export function usePublicDrafts(filters: PublicDraftFilters) {
+ const [drafts, setDrafts] = useState([]);
+
+ useEffect(() => {
+ setDrafts([]);
+ if (!filters.author && !filters.initiative) {
+ return;
+ }
+ let active = true;
+ const loadAll = async () => {
+ const items: PublicProposalDraftListItemDto[] = [];
+ let cursor: string | undefined;
+ do {
+ const response = await apiPublicProposalDrafts({
+ ...filters,
+ cursor,
+ limit: 100,
+ });
+ items.push(...response.items);
+ cursor = response.nextCursor;
+ } while (cursor && active);
+ if (active) setDrafts(items);
+ };
+ void loadAll().catch(() => {
+ if (active) setDrafts([]);
+ });
+ return () => {
+ active = false;
+ };
+ }, [filters.author, filters.initiative]);
+
+ return drafts;
+}
diff --git a/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx b/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx
index ed0ad54..fbe4098 100644
--- a/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx
+++ b/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx
@@ -234,6 +234,9 @@ type WizardActionsProps = {
continueDisabled?: boolean;
onBack: () => void;
onContinue: () => void;
+ onSecondaryAction?: () => void;
+ secondaryActionDisabled?: boolean;
+ secondaryActionLabel?: string;
submitting: boolean;
};
@@ -244,6 +247,9 @@ export function WizardActions({
continueLabel,
onBack,
onContinue,
+ onSecondaryAction,
+ secondaryActionDisabled = false,
+ secondaryActionLabel,
submitting,
}: WizardActionsProps) {
return (
@@ -256,13 +262,25 @@ export function WizardActions({
>
{backLabel}
-
+
+ {onSecondaryAction && secondaryActionLabel ? (
+
+ ) : null}
+
+
);
}
diff --git a/src/pages/proposals/proposalCreation/publicationReadiness.ts b/src/pages/proposals/proposalCreation/publicationReadiness.ts
new file mode 100644
index 0000000..165debd
--- /dev/null
+++ b/src/pages/proposals/proposalCreation/publicationReadiness.ts
@@ -0,0 +1,15 @@
+import type { ProposalDraftForm, ProposalTemplateId } from "./types";
+import { validateSystemDraftTarget } from "./wizardModel";
+
+export function isProposalDraftPublicationReady(
+ draft: ProposalDraftForm,
+ templateId: ProposalTemplateId,
+): boolean {
+ if (!draft.title.trim() || !draft.summary.trim() || !draft.chamberId.trim()) {
+ return false;
+ }
+ if (templateId === "system") {
+ return validateSystemDraftTarget(draft).valid;
+ }
+ return Boolean(draft.what.trim() && draft.why.trim());
+}
diff --git a/src/pages/proposals/proposalCreation/submitErrorRouting.ts b/src/pages/proposals/proposalCreation/submitErrorRouting.ts
index 2421c9b..f20e126 100644
--- a/src/pages/proposals/proposalCreation/submitErrorRouting.ts
+++ b/src/pages/proposals/proposalCreation/submitErrorRouting.ts
@@ -29,6 +29,14 @@ const scopeCodes = new Set([
"initiative_association_immutable",
]);
+const publicationContentCodes = new Set([
+ "draft_publication_title_required",
+ "draft_publication_summary_required",
+ "draft_publication_chamber_required",
+ "draft_publication_case_required",
+ "draft_publication_rationale_required",
+]);
+
export function proposalSubmitErrorStep(
error: unknown,
pathId: WizardPathId,
@@ -41,6 +49,15 @@ export function proposalSubmitErrorStep(
if (scopeCodes.has(code)) {
return pathId === "system-change" ? "system-change" : "essentials";
}
+ if (publicationContentCodes.has(code)) {
+ return pathId === "system-change" ? "system-change" : "essentials";
+ }
+ if (code === "draft_publication_system_action_required") {
+ return "system-change";
+ }
+ if (code === "draft_publication_invalid_url") {
+ return pathId === "system-change" ? "rationale" : "plan";
+ }
if (code === "draft_not_submittable") {
return firstIncompleteWizardStep(pathId, context);
}
diff --git a/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts b/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts
index 6755f34..96170c9 100644
--- a/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts
+++ b/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts
@@ -4,6 +4,7 @@ import type { NavigateFunction } from "react-router";
import { apiProposalDraft, apiProposalStatus } from "@/lib/apiClient";
import { normalizeSessionDraft } from "./sessionStorage";
import { inferPresetIdFromDraft } from "./presets/registry";
+import type { DraftPublicationSummaryDto } from "@/types/api";
import type { ProposalDraftForm } from "./types";
type ProposalCreationTemplateKind = "project" | "system";
@@ -12,6 +13,7 @@ export type ProposalDraftHydrationResult = {
draft: ProposalDraftForm;
draftId: string;
presetId: string;
+ publication: DraftPublicationSummaryDto;
templateKind: ProposalCreationTemplateKind;
};
@@ -71,6 +73,7 @@ export function useProposalDraftHydration({
draft: normalized,
draftId: nextDraftId,
presetId: nextPresetId,
+ publication: detail.publication,
templateKind: nextTemplateKind,
});
} catch (error) {
diff --git a/src/pages/proposals/proposalCreation/wizardModel.ts b/src/pages/proposals/proposalCreation/wizardModel.ts
index d7dcc5c..5194609 100644
--- a/src/pages/proposals/proposalCreation/wizardModel.ts
+++ b/src/pages/proposals/proposalCreation/wizardModel.ts
@@ -56,6 +56,7 @@ export type StepValidation = {
export type WizardContext = {
draft: ProposalDraftForm;
presetId: string;
+ publicationReady?: boolean;
tierBlocked: boolean;
};
@@ -195,7 +196,9 @@ export function proposalBudgetTotal(draft: ProposalDraftForm): number {
: positiveAmountTotal(draft.budgetItems, (item) => item.amount);
}
-function systemTargetValid(draft: ProposalDraftForm): StepValidation {
+export function validateSystemDraftTarget(
+ draft: ProposalDraftForm,
+): StepValidation {
if (draft.title.trim().length === 0) {
return { valid: false, firstInvalidFieldId: "title" };
}
@@ -254,7 +257,7 @@ export function validateWizardStep(
}
return { valid: true };
}
- if (stepId === "system-change") return systemTargetValid(draft);
+ if (stepId === "system-change") return validateSystemDraftTarget(draft);
if (stepId === "plan" || stepId === "rationale") {
return draft.how.trim().length > 0
? { valid: true }
@@ -289,10 +292,14 @@ export function reachableWizardSteps(
context: WizardContext,
): WizardStepId[] {
const result: WizardStepId[] = [];
- for (const step of WIZARD_PATHS[pathId].steps) {
+ const definition = WIZARD_PATHS[pathId];
+ for (const step of definition.steps) {
result.push(step.id);
if (!validateWizardStep(step.id, context).valid) break;
}
+ if (context.publicationReady && !result.includes("review")) {
+ result.push("review");
+ }
return result;
}
diff --git a/src/types/api.ts b/src/types/api.ts
index bb6e695..c2b4400 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -670,9 +670,30 @@ export type ProposalStatusDto = {
| "completed";
pendingMilestoneIndex?: number | null;
initiative?: InitiativeReferenceDto;
+ draftHistory?: {
+ draftId: string;
+ route: string;
+ revision: number;
+ };
updatedAt: string;
};
+export type DraftPublicationStatusDto =
+ | "private"
+ | "published"
+ | "withdrawn"
+ | "submitted";
+
+export type DraftPublicationSummaryDto = {
+ status: DraftPublicationStatusDto;
+ revision?: number;
+ publicUrl?: string;
+ publishedAt?: string;
+ publicUpdatedAt?: string;
+ hasUnpublishedChanges?: boolean;
+ submittedProposalRoute?: string;
+};
+
export type ProposalDraftListItemDto = {
id: string;
title: string;
@@ -680,9 +701,31 @@ export type ProposalDraftListItemDto = {
tier: string;
summary: string;
updated: string;
+ publication: DraftPublicationSummaryDto;
};
export type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] };
+export type PublicProposalDraftKindDto = "policy" | "formation" | "system";
+export type PublicProposalDraftSortDto = "updated" | "published";
+
+export type PublicProposalDraftListItemDto = {
+ id: string;
+ title: string;
+ chamber: string;
+ summary: string;
+ proposer: string;
+ proposalKind: PublicProposalDraftKindDto;
+ revision: number;
+ publishedAt: string;
+ updatedAt: string;
+ initiative?: InitiativeReferenceDto;
+};
+
+export type GetPublicProposalDraftsResponse = {
+ items: PublicProposalDraftListItemDto[];
+ nextCursor?: string;
+};
+
export type ProposalDraftEditableFormDto = {
templateId?: "project" | "system";
presetId?: string;
@@ -743,7 +786,9 @@ export type ProposalDraftDetailDto = {
teamSlots: string;
milestonesPlanned: string;
summary: string;
+ overview: string;
rationale: string;
+ executionPlan: string[];
budgetScope: string;
checklist: string[];
milestones: string[];
@@ -751,6 +796,9 @@ export type ProposalDraftDetailDto = {
openSlotNeeds: { title: string; desc: string }[];
milestonesDetail: { title: string; desc: string }[];
attachments: { title: string; href?: string }[];
+ authoring: ProposalAuthoringDetailsDto;
+ publication: DraftPublicationSummaryDto;
+ initiative?: InitiativeReferenceDto;
editableForm?: ProposalDraftEditableFormDto;
};
@@ -1103,6 +1151,7 @@ export type HumanNodeDto = {
memberSince: string;
formationCapable?: boolean;
active: {
+ governor: boolean;
governorActive: boolean;
humanNodeActive: boolean;
};
@@ -1161,6 +1210,7 @@ export type HumanDelegationChamberDto = {
export type HumanNodeProfileDto = {
id: string;
name: string;
+ governor: boolean;
governorActive: boolean;
humanNodeActive: boolean;
governanceSummary: string;
diff --git a/src/types/stages.ts b/src/types/stages.ts
index a97e6dc..44b733b 100644
--- a/src/types/stages.ts
+++ b/src/types/stages.ts
@@ -27,9 +27,10 @@ export const feedStages = [
export type FeedStage = (typeof feedStages)[number];
-export type Stage = ProposalStage | FeedStage;
+export type Stage = ProposalStage | FeedStage | "draft";
export type StageChipKind =
+ | "draft"
| "proposal_pool"
| "chamber_vote"
| "citizen_veto"
@@ -44,6 +45,7 @@ export type StageChipKind =
| "system";
export const stageToChipKind = {
+ draft: "draft",
pool: "proposal_pool",
vote: "chamber_vote",
citizen_veto: "citizen_veto",
@@ -59,6 +61,7 @@ export const stageToChipKind = {
} as const satisfies Record;
export const stageLabel = {
+ draft: "Draft",
pool: "Proposal pool",
vote: "Chamber vote",
citizen_veto: "Citizen veto",
diff --git a/tests/e2e/proposal-wizard.spec.ts b/tests/e2e/proposal-wizard.spec.ts
index 3103fb9..d54850d 100644
--- a/tests/e2e/proposal-wizard.spec.ts
+++ b/tests/e2e/proposal-wizard.spec.ts
@@ -38,7 +38,9 @@ function draftDetail(id: string, editableForm: typeof existingDraftForm) {
teamSlots: "0",
milestonesPlanned: "0",
summary: editableForm.summary,
+ overview: editableForm.what,
rationale: editableForm.why,
+ executionPlan: [],
budgetScope: "No Formation budget",
checklist: [],
milestones: [],
@@ -46,6 +48,18 @@ function draftDetail(id: string, editableForm: typeof existingDraftForm) {
openSlotNeeds: [],
milestonesDetail: [],
attachments: [],
+ authoring: {
+ kind: "project",
+ presetId: editableForm.presetId,
+ proposalType: editableForm.proposalType,
+ what: editableForm.what,
+ why: editableForm.why,
+ how: editableForm.how,
+ aboutMe: editableForm.aboutMe,
+ outputs: [],
+ budgetItems: [],
+ },
+ publication: { status: "private" },
editableForm,
};
}
@@ -160,6 +174,7 @@ async function installApiFixtures(page: Page) {
json: {
id: address,
name: "Test Governor",
+ governor: true,
governorActive: true,
humanNodeActive: true,
governanceSummary: "",
@@ -189,6 +204,20 @@ async function installApiFixtures(page: Page) {
});
return;
}
+ if (body.type === "proposal.draft.publish") {
+ await route.fulfill({
+ json: {
+ ok: true,
+ type: body.type,
+ draftId: "draft-e2e",
+ revision: 1,
+ publicUrl: "/app/proposals/public-drafts/draft-e2e",
+ publishedAt: "2026-07-02T12:00:00.000Z",
+ updatedAt: "2026-07-02T12:00:00.000Z",
+ },
+ });
+ return;
+ }
if (body.type === "proposal.submitToPool") {
await route.fulfill({
json: {
@@ -349,6 +378,37 @@ test("fresh entry ignores legacy Review state and completes policy submission",
await expect(page).toHaveURL(/\/app\/proposals\/proposal-e2e\/pp$/);
});
+test("Review publishes an explicit public snapshot without submitting", async ({
+ page,
+}) => {
+ await openFreshWizard(page);
+ await page.locator("#proposal-kind").selectOption("project");
+ await page.locator("#proposal-type").selectOption("basic");
+ await page.locator("#proposal-formation-mode").selectOption("policy");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.locator("#title").fill("Draft before governance");
+ await page.locator("#chamber").selectOption("general");
+ await page.locator("#summary").fill("Invite review before submission.");
+ await page.locator("#what").fill("Publish a readable snapshot.");
+ await page.locator("#why").fill("Catch mistakes before formal voting.");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.locator("#how").fill("Review, revise, and submit separately.");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect(page.locator("#agree-rules")).not.toBeChecked();
+ await expect(page.locator("#confirm-budget")).not.toBeChecked();
+
+ const publishRequest = page.waitForRequest((request) => {
+ if (!request.url().endsWith("/api/command")) return false;
+ return request.postDataJSON()?.type === "proposal.draft.publish";
+ });
+ await page.getByRole("button", { name: "Publish draft" }).click();
+ await publishRequest;
+ await expect(
+ page.getByRole("button", { name: "Update public draft" }),
+ ).toBeVisible();
+ await expect(page).toHaveURL(/step=review/);
+});
+
test("submission locks draft-changing controls until the pool response returns", async ({
page,
}) => {
@@ -561,6 +621,88 @@ test("submission synchronizes the newest edit after an earlier save is still pen
expect(submittedDraftId).toBe("draft-latest-revision");
});
+test("an ambiguous submission failure retries without rewriting the submitted draft", async ({
+ page,
+}) => {
+ let saveCount = 0;
+ const submitKeys: string[] = [];
+
+ await openFreshWizard(page);
+ await page.route("**/api/command", async (route) => {
+ const request = route.request();
+ const body = request.postDataJSON() as { type?: string };
+ if (body.type === "proposal.draft.save") {
+ saveCount += 1;
+ await route.fulfill({
+ json: {
+ ok: true,
+ type: body.type,
+ draftId: "draft-ambiguous-submit",
+ updatedAt: "2026-07-03T12:00:00.000Z",
+ },
+ });
+ return;
+ }
+ if (body.type === "proposal.submitToPool") {
+ submitKeys.push(request.headers()["idempotency-key"] ?? "");
+ if (submitKeys.length === 1) {
+ await route.fulfill({
+ status: 503,
+ json: {
+ error: {
+ code: "temporarily_unavailable",
+ message: "Submission response was interrupted.",
+ },
+ },
+ });
+ return;
+ }
+ await route.fulfill({
+ json: {
+ ok: true,
+ type: body.type,
+ proposalId: "proposal-recovered-submit",
+ },
+ });
+ return;
+ }
+ await route.fallback();
+ });
+
+ await page.locator("#proposal-kind").selectOption("project");
+ await page.locator("#proposal-type").selectOption("basic");
+ await page.locator("#proposal-formation-mode").selectOption("policy");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.locator("#title").fill("Recover uncertain submission");
+ await page.locator("#chamber").selectOption("general");
+ await page.locator("#summary").fill("Retry without rewriting the draft.");
+ await page.locator("#what").fill("Preserve an accepted proposal command.");
+ await page
+ .locator("#why")
+ .fill("Network failures cannot reveal commit state.");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.locator("#how").fill("Replay the same idempotent submission.");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.locator("#agree-rules").check();
+ await page.locator("#confirm-budget").check();
+
+ await page.getByRole("button", { name: "Submit proposal" }).click();
+ await expect(
+ page.getByText("Submission response was interrupted."),
+ ).toBeVisible();
+ expect(saveCount).toBe(1);
+ expect(submitKeys).toHaveLength(1);
+
+ await page.getByRole("button", { name: "Submit proposal" }).click();
+ await expect(page).toHaveURL(
+ /\/app\/proposals\/proposal-recovered-submit\/pp$/,
+ );
+ expect(saveCount).toBe(1);
+ expect(submitKeys).toHaveLength(2);
+ expect(submitKeys[0]).not.toBe("");
+ expect(submitKeys[1]).toBe(submitKeys[0]);
+});
+
test("a keyboard-only author can complete a policy proposal", async ({
page,
}) => {
diff --git a/tests/e2e/public-drafts.spec.ts b/tests/e2e/public-drafts.spec.ts
new file mode 100644
index 0000000..7df2c75
--- /dev/null
+++ b/tests/e2e/public-drafts.spec.ts
@@ -0,0 +1,321 @@
+import { expect, test, type Page } from "@playwright/test";
+
+const draftId = "draft-public-governance";
+const author = "hmr1GRb1SRdDfJZmFaYh5L1RNev3dFcTVLGS2Rqqmk3Fbgj2W";
+
+const listItem = {
+ id: draftId,
+ title: "Transparent governance reporting",
+ chamber: "General Chamber",
+ summary: "Publish regular governance reports before formal submission.",
+ proposer: author,
+ proposalKind: "policy",
+ revision: 2,
+ publishedAt: "2026-07-22T10:00:00.000Z",
+ updatedAt: "2026-07-23T12:30:00.000Z",
+ initiative: {
+ id: "governance-observatory",
+ slug: "governance-observatory",
+ title: "Governance Observatory",
+ },
+};
+
+const detail = {
+ id: draftId,
+ submittedAt: null,
+ submittedProposalId: null,
+ title: listItem.title,
+ proposer: author,
+ chamber: "General Chamber",
+ focus: "Basic",
+ tier: "Consul",
+ budget: "0 HMND",
+ formationEligible: false,
+ teamSlots: "0 / 0",
+ milestonesPlanned: "0",
+ summary: listItem.summary,
+ overview: "Create a public reporting policy with readable evidence.",
+ rationale:
+ "Let reviewers catch mistakes before the proposal enters governance.",
+ executionPlan: [
+ "Publish a signed report at the end of every governance era.",
+ ],
+ budgetScope: "No Formation budget",
+ checklist: [],
+ milestones: [],
+ teamLocked: [],
+ openSlotNeeds: [],
+ milestonesDetail: [],
+ attachments: [],
+ authoring: {
+ kind: "project",
+ presetId: "project.policy",
+ proposalType: "basic",
+ what: "Create a public reporting policy with readable evidence.",
+ why: "Let reviewers catch mistakes before the proposal enters governance.",
+ how: "Publish a signed report at the end of every governance era.",
+ aboutMe: "",
+ outputs: [],
+ timeline: [],
+ budgetItems: [],
+ systemAction: {
+ action: null,
+ chamberId: null,
+ targetAddress: null,
+ title: null,
+ multiplier: null,
+ genesisMembers: [],
+ },
+ },
+ publication: {
+ status: "published",
+ revision: 2,
+ publicUrl: `/app/proposals/public-drafts/${draftId}`,
+ publishedAt: listItem.publishedAt,
+ publicUpdatedAt: listItem.updatedAt,
+ },
+ initiative: listItem.initiative,
+};
+
+async function installFixtures(page: Page) {
+ await page.route("**/api/**", async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === "/api/me") {
+ await route.fulfill({ json: { authenticated: false } });
+ return;
+ }
+ if (url.pathname === "/api/proposals/public-drafts") {
+ await route.fulfill({ json: { items: [listItem] } });
+ return;
+ }
+ if (url.pathname === `/api/proposals/public-drafts/${draftId}`) {
+ await route.fulfill({ json: detail });
+ return;
+ }
+ await route.fulfill({ json: { items: [] } });
+ });
+}
+
+test("anonymous readers can discover and open a complete public draft", async ({
+ page,
+}) => {
+ await installFixtures(page);
+ await page.goto("/app/proposals/public-drafts");
+ await expect(
+ page.getByRole("heading", { name: "Public drafts", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByText(listItem.title, { exact: true })).toBeVisible();
+ await page.getByRole("article").getByRole("button").click();
+ await page.getByRole("link", { name: "Read draft" }).click();
+ await expect(page).toHaveURL(`/app/proposals/public-drafts/${draftId}`);
+ await expect(
+ page.getByRole("heading", { name: listItem.title, exact: true }),
+ ).toBeVisible();
+ await expect(page.getByText("Draft", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText(detail.overview, { exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Continue editing" }),
+ ).toHaveCount(0);
+});
+
+test("public draft owners receive one coherent action group", async ({
+ page,
+}) => {
+ await page.route("**/api/**", async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === "/api/me") {
+ await route.fulfill({
+ json: {
+ authenticated: true,
+ address: author,
+ gate: {
+ eligible: true,
+ expiresAt: "2026-07-24T20:00:00.000Z",
+ },
+ },
+ });
+ return;
+ }
+ if (url.pathname === `/api/proposals/public-drafts/${draftId}`) {
+ await route.fulfill({ json: detail });
+ return;
+ }
+ await route.fulfill({ json: { items: [] } });
+ });
+
+ await page.goto(`/app/proposals/public-drafts/${draftId}`);
+ await expect(page.getByRole("button", { name: "Copy link" })).toHaveCount(1);
+ await expect(
+ page.getByRole("link", { name: "Continue editing" }),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Unpublish" })).toBeVisible();
+});
+
+test("changing the directory query retires the previous pagination cursor", async ({
+ page,
+}) => {
+ let mixedCursorRequest = false;
+ await page.route("**/api/**", async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === "/api/me") {
+ await route.fulfill({ json: { authenticated: false } });
+ return;
+ }
+ if (url.pathname === "/api/proposals/public-drafts") {
+ if (url.searchParams.get("q") && url.searchParams.get("cursor")) {
+ mixedCursorRequest = true;
+ }
+ if (url.searchParams.get("q")) {
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
+ await route.fulfill({
+ json: url.searchParams.get("q")
+ ? { items: [] }
+ : { items: [listItem], nextCursor: "cursor-from-old-query" },
+ });
+ return;
+ }
+ await route.fulfill({ json: { items: [] } });
+ });
+
+ await page.goto("/app/proposals/public-drafts");
+ await expect(page.getByRole("button", { name: "Load more" })).toBeVisible();
+ await page.getByLabel("Search public drafts").fill("different");
+ await page.waitForTimeout(350);
+ expect(await page.getByRole("button", { name: "Load more" }).count()).toBe(0);
+ expect(mixedCursorRequest).toBe(false);
+});
+
+test("My Drafts keeps private and public drafts together and toggles visibility", async ({
+ page,
+}) => {
+ const privateDraft = {
+ id: "owned-private-draft",
+ title: "Private policy notes",
+ chamber: "General Chamber",
+ tier: "Consul",
+ summary: "Notes that are ready to become a public draft.",
+ updated: "2026-07-24T13:00:00.000Z",
+ publication: { status: "private" },
+ };
+ const publicDraft = {
+ id: "owned-public-draft",
+ title: "Published governance outline",
+ chamber: "General Chamber",
+ tier: "Consul",
+ summary: "An owned draft that is already visible publicly.",
+ updated: "2026-07-24T12:00:00.000Z",
+ publication: {
+ status: "published",
+ revision: 2,
+ publicUrl: "/app/proposals/public-drafts/owned-public-draft",
+ publishedAt: "2026-07-23T12:00:00.000Z",
+ publicUpdatedAt: "2026-07-24T12:00:00.000Z",
+ hasUnpublishedChanges: false,
+ },
+ };
+ await page.route("**/api/**", async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (url.pathname === "/api/me") {
+ await route.fulfill({
+ json: {
+ authenticated: true,
+ address: author,
+ gate: { eligible: true, expiresAt: "2026-08-01T00:00:00.000Z" },
+ },
+ });
+ return;
+ }
+ if (url.pathname === "/api/proposals/drafts") {
+ await route.fulfill({ json: { items: [privateDraft, publicDraft] } });
+ return;
+ }
+ if (url.pathname.startsWith("/api/humans/")) {
+ await route.fulfill({
+ json: {
+ id: author,
+ name: "Draft Author",
+ humanNodeActive: true,
+ governor: true,
+ governorActive: false,
+ heroStats: [],
+ quickDetails: [],
+ proofSections: {},
+ governanceActions: [],
+ delegation: { chambers: [] },
+ delegationEligibleChambers: [],
+ projects: [],
+ activity: [],
+ history: [],
+ },
+ });
+ return;
+ }
+ if (url.pathname === "/api/command" && request.method() === "POST") {
+ const body = request.postDataJSON() as {
+ type?: string;
+ payload?: { draftId?: string };
+ };
+ if (body.type === "proposal.draft.publish") {
+ await route.fulfill({
+ json: {
+ ok: true,
+ type: body.type,
+ draftId: body.payload?.draftId,
+ revision: 1,
+ publicUrl: `/app/proposals/public-drafts/${body.payload?.draftId}`,
+ publishedAt: "2026-07-24T14:00:00.000Z",
+ updatedAt: "2026-07-24T14:00:00.000Z",
+ },
+ });
+ return;
+ }
+ if (body.type === "proposal.draft.unpublish") {
+ await route.fulfill({
+ json: {
+ ok: true,
+ type: body.type,
+ draftId: body.payload?.draftId,
+ unpublished: true,
+ updatedAt: "2026-07-24T14:05:00.000Z",
+ },
+ });
+ return;
+ }
+ }
+ await route.fulfill({ json: { items: [] } });
+ });
+
+ await page.goto("/app/proposals/drafts");
+ const authPanel = page.locator(".sidebar__auth");
+ const governorRow = authPanel.locator(".sidebar__authRow", {
+ has: page.getByText("Governor", { exact: true }),
+ });
+ const activeGovernorRow = authPanel.locator(".sidebar__authRow", {
+ has: page.getByText("Active governor", { exact: true }),
+ });
+ await expect(governorRow.getByText("Active", { exact: true })).toBeVisible();
+ await expect(
+ activeGovernorRow.getByText("Not active", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(privateDraft.title, { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(publicDraft.title, { exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Make draft public" }).click();
+ await expect(
+ page.getByRole("button", { name: "Make draft private" }),
+ ).toHaveCount(2);
+
+ page.once("dialog", (dialog) => void dialog.accept());
+ await page
+ .getByRole("button", { name: "Make draft private" })
+ .first()
+ .click();
+ await expect(
+ page.getByRole("button", { name: "Make draft public" }),
+ ).toHaveCount(1);
+});
diff --git a/tests/unit/human-nodes-ui.test.ts b/tests/unit/human-nodes-ui.test.ts
index 8882204..687af96 100644
--- a/tests/unit/human-nodes-ui.test.ts
+++ b/tests/unit/human-nodes-ui.test.ts
@@ -9,6 +9,7 @@ import {
getHumanNodeManageableDelegationChambers,
getHumanNodeViewerDelegationByChamber,
getHumanNodeVisibleHeroStats,
+ governanceIdentityStatuses,
isLikelyHumanodeAddress,
shouldShowHumanNodeShortBadge,
} from "../../src/lib/humanNodesUi";
@@ -32,6 +33,7 @@ const node = (
mm: 0,
memberSince: "2026-01-01T00:00:00.000Z",
active: {
+ governor: false,
governorActive: false,
humanNodeActive: false,
},
@@ -44,6 +46,7 @@ const profile = (
): HumanNodeProfileDto => ({
id: "hmpt3fxBvpWrkZxq5H5uWjZ2BgHRMJs2hKHiWJDoqD7am1xPs",
name: "Human Node Profile",
+ governor: true,
governorActive: true,
humanNodeActive: true,
governanceSummary: "",
@@ -87,6 +90,24 @@ test("isLikelyHumanodeAddress identifies long hm addresses only", () => {
expect(isLikelyHumanodeAddress("hm-short")).toBe(false);
});
+test("governor and active-governor labels preserve distinct semantics", () => {
+ expect(
+ governanceIdentityStatuses({
+ governor: true,
+ activeGovernor: false,
+ humanNode: true,
+ }),
+ ).toEqual({
+ governor: { label: "Governor", value: "Active", active: true },
+ activeGovernor: {
+ label: "Active governor",
+ value: "Not active",
+ active: false,
+ },
+ humanNode: { label: "Human node", value: "Active", active: true },
+ });
+});
+
test("filterHumanNodes searches chamber and faction names", () => {
const result = filterHumanNodes({
chambersById: {
@@ -122,12 +143,16 @@ test("filterHumanNodes sorts by ACM and honors active status", () => {
},
nodes: [
node("low", {
- active: { governorActive: true, humanNodeActive: true },
+ active: { governor: true, governorActive: true, humanNodeActive: true },
acm: 10,
}),
node("inactive", { acm: 100 }),
node("high", {
- active: { governorActive: true, humanNodeActive: false },
+ active: {
+ governor: true,
+ governorActive: true,
+ humanNodeActive: false,
+ },
cmTotals: { lcm: 0, mcm: 0, acm: 50 },
}),
],
diff --git a/tests/unit/proposal-draft-ui.test.ts b/tests/unit/proposal-draft-ui.test.ts
new file mode 100644
index 0000000..c1ff7c7
--- /dev/null
+++ b/tests/unit/proposal-draft-ui.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from "@rstest/core";
+
+import {
+ canPublishDraft,
+ editDraftRoute,
+ isPublicDraftVisible,
+ ownerDraftRoute,
+ publicationRoute,
+ publicDraftRoute,
+ reconsiderProposalRoute,
+} from "../../src/pages/proposals/draft/draftUi";
+
+describe("proposal draft UI contracts", () => {
+ test("builds encoded owner, editor, public, and reconsideration routes", () => {
+ expect(ownerDraftRoute("draft / one")).toBe(
+ "/app/proposals/drafts/draft%20%2F%20one",
+ );
+ expect(editDraftRoute("draft / one")).toBe(
+ "/app/proposals/new?draftId=draft%20%2F%20one",
+ );
+ expect(publicDraftRoute("draft / one")).toBe(
+ "/app/proposals/public-drafts/draft%20%2F%20one",
+ );
+ expect(reconsiderProposalRoute("proposal / one")).toBe(
+ "/app/proposals/new?resubmitsProposalId=proposal%20%2F%20one",
+ );
+ });
+
+ test("uses server routes and keeps publication states explicit", () => {
+ expect(
+ publicationRoute("draft-1", {
+ status: "published",
+ publicUrl: "/canonical/draft-1",
+ }),
+ ).toBe("/canonical/draft-1");
+ expect(canPublishDraft({ status: "private" })).toBe(true);
+ expect(
+ canPublishDraft({
+ status: "published",
+ hasUnpublishedChanges: false,
+ }),
+ ).toBe(false);
+ expect(isPublicDraftVisible({ status: "submitted" })).toBe(true);
+ expect(isPublicDraftVisible({ status: "withdrawn" })).toBe(false);
+ });
+});
diff --git a/tests/unit/proposal-stage-navigation.test.ts b/tests/unit/proposal-stage-navigation.test.ts
index cabfb77..de1b514 100644
--- a/tests/unit/proposal-stage-navigation.test.ts
+++ b/tests/unit/proposal-stage-navigation.test.ts
@@ -44,3 +44,18 @@ test("proposal stage navigation links terminal live stage and prior formation sn
assert.equal(links.passed, "/app/proposals/p-3/finished");
assert.equal(links.failed, undefined);
});
+
+test("proposal stage navigation exposes Draft only from server-provided history", () => {
+ const withoutHistory = buildProposalStageLinks({
+ liveStage: "vote",
+ proposalId: "p-4",
+ });
+ assert.equal(withoutHistory.draft, undefined);
+
+ const withHistory = buildProposalStageLinks({
+ draftRoute: "/app/proposals/public-drafts/draft-p-4",
+ liveStage: "vote",
+ proposalId: "p-4",
+ });
+ assert.equal(withHistory.draft, "/app/proposals/public-drafts/draft-p-4");
+});
diff --git a/tests/unit/proposal-wizard-model.test.ts b/tests/unit/proposal-wizard-model.test.ts
index 8ef6573..aa8c76c 100644
--- a/tests/unit/proposal-wizard-model.test.ts
+++ b/tests/unit/proposal-wizard-model.test.ts
@@ -16,6 +16,7 @@ import {
validateWizardStep,
type WizardContext,
} from "../../src/pages/proposals/proposalCreation/wizardModel";
+import { isProposalDraftPublicationReady } from "../../src/pages/proposals/proposalCreation/publicationReadiness";
function completePolicyDraft(): ProposalDraftForm {
return {
@@ -79,6 +80,48 @@ test("reachable steps stop after the first invalid requirement", () => {
).toEqual(["intent", "essentials", "plan"]);
});
+test("public review can be reached before the formal submission path is complete", () => {
+ const draft = {
+ ...completePolicyDraft(),
+ chamberId: "general",
+ summary: "Ready for public review",
+ how: "",
+ agreeRules: false,
+ confirmBudget: false,
+ };
+ expect(isProposalDraftPublicationReady(draft, "project")).toBe(true);
+ expect(
+ reachableWizardSteps("project-policy", {
+ ...context(draft),
+ publicationReady: true,
+ }),
+ ).toEqual(["intent", "essentials", "plan", "review"]);
+ expect(validateWizardStep("review", context(draft)).valid).toBe(false);
+});
+
+test("system publication requires the action's canonical target fields", () => {
+ const draft: ProposalDraftForm = {
+ ...completePolicyDraft(),
+ title: "Create a chamber",
+ chamberId: "general",
+ summary: "Open a public system Draft.",
+ metaGovernance: {
+ action: "chamber.create",
+ chamberId: "research",
+ },
+ };
+ expect(isProposalDraftPublicationReady(draft, "system")).toBe(false);
+ expect(
+ isProposalDraftPublicationReady(
+ {
+ ...draft,
+ metaGovernance: { ...draft.metaGovernance!, title: "Research" },
+ },
+ "system",
+ ),
+ ).toBe(true);
+});
+
test("Formation funding validates every milestone budget", () => {
const draft = {
...completePolicyDraft(),
diff --git a/tests/unit/proposal-wizard-submit-routing.test.ts b/tests/unit/proposal-wizard-submit-routing.test.ts
index cec2011..e850245 100644
--- a/tests/unit/proposal-wizard-submit-routing.test.ts
+++ b/tests/unit/proposal-wizard-submit-routing.test.ts
@@ -59,3 +59,20 @@ test("incomplete server drafts resolve to the first incomplete local step", () =
),
).toBe("plan");
});
+
+test("publication validation returns to the path-owned content step", () => {
+ expect(
+ proposalSubmitErrorStep(
+ apiError("draft_publication_summary_required"),
+ "project-policy",
+ context,
+ ),
+ ).toBe("essentials");
+ expect(
+ proposalSubmitErrorStep(
+ apiError("draft_publication_system_action_required"),
+ "system-change",
+ context,
+ ),
+ ).toBe("system-change");
+});