From 21e3b8c8b9b991604f10c6633a83983c59e9ceb6 Mon Sep 17 00:00:00 2001
From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com>
Date: Mon, 20 Jul 2026 17:45:57 +0400
Subject: [PATCH 1/4] Rebuild proposal wizard flow
---
.gitignore | 2 +
package.json | 2 +
playwright.config.ts | 22 +
rstest.config.ts | 1 +
src/app/AppSidebar.css | 2 +-
src/app/MainAtmosphere.css | 2 +-
src/components/ProposalNarrative.css | 98 ++
src/components/ProposalNarrative.tsx | 294 ++++++
src/components/ProposalSections.tsx | 9 +-
src/pages/proposals/ProposalCreation.tsx | 891 ++++++++++++------
src/pages/proposals/ProposalDraft.tsx | 86 +-
src/pages/proposals/ProposalDrafts.tsx | 6 +-
.../ProposalCreationMessages.tsx | 17 +-
.../ProposalCreationStepCard.tsx | 195 ----
.../ProposalCreationToolbar.tsx | 82 --
.../proposalCreation/ProposalWizard.css | 304 ++++++
.../proposalCreation/ProposalWizardShell.tsx | 304 ++++++
.../proposalCreation/WizardFieldSection.tsx | 29 +
.../proposalCreation/sessionStorage.ts | 311 ++++++
.../proposalCreation/steps/BudgetStep.tsx | 7 +-
.../proposalCreation/steps/EssentialsStep.tsx | 768 ---------------
.../proposalCreation/steps/IntentStep.tsx | 354 +++++++
.../proposalCreation/steps/PlanStep.tsx | 42 +-
.../steps/ProjectEssentialsStep.tsx | 150 +++
.../proposalCreation/steps/ReviewStep.tsx | 37 +-
.../steps/SystemChangeStep.tsx | 233 +++++
.../proposals/proposalCreation/storage.ts | 106 ---
.../proposalCreation/submitErrorRouting.ts | 51 +
.../useProposalCreationPreset.ts | 109 ---
.../useProposalDraftHydration.ts | 17 +-
.../proposals/proposalCreation/wizardModel.ts | 414 ++++++++
tests/e2e/proposal-wizard.spec.ts | 523 ++++++++++
tests/unit/proposal-narrative.test.ts | 46 +
tests/unit/proposal-wizard-model.test.ts | 153 +++
.../proposal-wizard-payload-parity.test.ts | 139 +++
tests/unit/proposal-wizard-session.test.ts | 144 +++
.../proposal-wizard-submit-routing.test.ts | 61 ++
.../unit/proposal-wizard-ui-contract.test.ts | 49 +
yarn.lock | 231 ++++-
39 files changed, 4634 insertions(+), 1657 deletions(-)
create mode 100644 playwright.config.ts
create mode 100644 src/components/ProposalNarrative.css
create mode 100644 src/components/ProposalNarrative.tsx
delete mode 100644 src/pages/proposals/proposalCreation/ProposalCreationStepCard.tsx
delete mode 100644 src/pages/proposals/proposalCreation/ProposalCreationToolbar.tsx
create mode 100644 src/pages/proposals/proposalCreation/ProposalWizard.css
create mode 100644 src/pages/proposals/proposalCreation/ProposalWizardShell.tsx
create mode 100644 src/pages/proposals/proposalCreation/WizardFieldSection.tsx
create mode 100644 src/pages/proposals/proposalCreation/sessionStorage.ts
delete mode 100644 src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx
create mode 100644 src/pages/proposals/proposalCreation/steps/IntentStep.tsx
create mode 100644 src/pages/proposals/proposalCreation/steps/ProjectEssentialsStep.tsx
create mode 100644 src/pages/proposals/proposalCreation/steps/SystemChangeStep.tsx
delete mode 100644 src/pages/proposals/proposalCreation/storage.ts
create mode 100644 src/pages/proposals/proposalCreation/submitErrorRouting.ts
delete mode 100644 src/pages/proposals/proposalCreation/useProposalCreationPreset.ts
create mode 100644 src/pages/proposals/proposalCreation/wizardModel.ts
create mode 100644 tests/e2e/proposal-wizard.spec.ts
create mode 100644 tests/unit/proposal-narrative.test.ts
create mode 100644 tests/unit/proposal-wizard-model.test.ts
create mode 100644 tests/unit/proposal-wizard-payload-parity.test.ts
create mode 100644 tests/unit/proposal-wizard-session.test.ts
create mode 100644 tests/unit/proposal-wizard-submit-routing.test.ts
create mode 100644 tests/unit/proposal-wizard-ui-contract.test.ts
diff --git a/.gitignore b/.gitignore
index c898689..7b48a12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,8 @@ node_modules
# production
dist
storybook-static
+playwright-report
+test-results
# ts
*.tsbuildinfo
diff --git a/package.json b/package.json
index 196f4e0..ef20360 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"build": "rsbuild build",
"preview": "rsbuild preview",
"test": "RSTEST=1 rstest",
+ "test:e2e": "playwright test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"prettier:check": "prettier --list-different .",
"prettier:fix": "prettier --write ."
@@ -29,6 +30,7 @@
"zod": "^4.2.1"
},
"devDependencies": {
+ "@playwright/test": "^1.61.1",
"@rsbuild/core": "^1.6.6",
"@rsbuild/plugin-react": "^1.4.2",
"@rstest/core": "^0.7.9",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..d0f3d4d
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests/e2e",
+ outputDir: "./test-results/playwright",
+ fullyParallel: false,
+ retries: 0,
+ reporter: "line",
+ use: {
+ baseURL: "http://127.0.0.1:4189",
+ colorScheme: "dark",
+ reducedMotion: "reduce",
+ screenshot: "only-on-failure",
+ trace: "retain-on-failure",
+ },
+ webServer: {
+ command: "yarn dev --host 127.0.0.1 --port 4189",
+ url: "http://127.0.0.1:4189",
+ reuseExistingServer: true,
+ timeout: 120_000,
+ },
+});
diff --git a/rstest.config.ts b/rstest.config.ts
index 9d89faa..9e78036 100644
--- a/rstest.config.ts
+++ b/rstest.config.ts
@@ -19,6 +19,7 @@ const rstestServerPlugin = (): RsbuildPlugin => ({
});
export default defineConfig({
+ exclude: ["tests/e2e/**"],
testMatch: ["tests/**/*.test.js"],
environment: "node",
browser: { enabled: false },
diff --git a/src/app/AppSidebar.css b/src/app/AppSidebar.css
index d440bbf..47418e6 100644
--- a/src/app/AppSidebar.css
+++ b/src/app/AppSidebar.css
@@ -442,7 +442,7 @@
padding: 0.95rem 1rem;
border-right: 0;
border-bottom: 1px solid var(--sidebar-border);
- overflow: visible;
+ overflow: clip;
}
.sidebar__brand {
diff --git a/src/app/MainAtmosphere.css b/src/app/MainAtmosphere.css
index ea9e433..22beed7 100644
--- a/src/app/MainAtmosphere.css
+++ b/src/app/MainAtmosphere.css
@@ -3,7 +3,7 @@
inset: 0 0 0 var(--app-sidebar-width, 260px);
z-index: 0;
pointer-events: none;
- overflow: hidden;
+ overflow: clip;
contain: paint;
}
diff --git a/src/components/ProposalNarrative.css b/src/components/ProposalNarrative.css
new file mode 100644
index 0000000..a4f2c5a
--- /dev/null
+++ b/src/components/ProposalNarrative.css
@@ -0,0 +1,98 @@
+.proposal-narrative {
+ display: grid;
+ gap: 0.75rem;
+ color: var(--muted);
+ font-size: 0.875rem;
+ line-height: 1.65;
+}
+
+.proposal-narrative__heading {
+ margin: 0.5rem 0 0;
+ color: var(--text);
+ font-size: 0.9375rem;
+ font-weight: 700;
+ line-height: 1.4;
+}
+
+.proposal-narrative__paragraph {
+ margin: 0;
+}
+
+.proposal-narrative__list {
+ display: grid;
+ gap: 0.375rem;
+ margin: 0;
+ padding-left: 1.25rem;
+}
+
+.proposal-narrative__quote {
+ margin: 0;
+ border-left: 2px solid var(--primary);
+ padding-left: 0.875rem;
+ color: var(--text);
+}
+
+.proposal-narrative-editor {
+ overflow: hidden;
+ border: 1px solid var(--surface-glass-border);
+ border-radius: 8px;
+ background: var(--control-glass-bg);
+ box-shadow: var(--shadow-control);
+ transition:
+ border-color 160ms ease,
+ background-color 160ms ease;
+}
+
+.proposal-narrative-editor:focus-within {
+ border-color: var(--primary-dim);
+ background: var(--control-glass-hover-bg);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-dim) 55%, transparent);
+}
+
+.proposal-narrative-editor__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ border-bottom: 1px solid var(--surface-glass-border);
+ padding: 0.375rem;
+}
+
+.proposal-narrative-editor__toolbar button {
+ min-height: 2rem;
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+ font-size: 0.75rem;
+}
+
+.proposal-narrative-editor__input {
+ display: block;
+ width: 100%;
+ resize: vertical;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ padding: 0.75rem;
+ color: var(--text);
+ font-size: 0.875rem;
+ line-height: 1.6;
+}
+
+.proposal-narrative-editor__input::placeholder {
+ color: var(--muted);
+ opacity: 0.75;
+}
+
+.proposal-narrative-editor__hint {
+ margin: 0;
+ border-top: 1px solid var(--surface-glass-border);
+ padding: 0.5rem 0.75rem;
+ color: var(--muted);
+ font-size: 0.75rem;
+ line-height: 1.45;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .proposal-narrative-editor {
+ transition: none;
+ }
+}
diff --git a/src/components/ProposalNarrative.tsx b/src/components/ProposalNarrative.tsx
new file mode 100644
index 0000000..053da4d
--- /dev/null
+++ b/src/components/ProposalNarrative.tsx
@@ -0,0 +1,294 @@
+import { useRef } from "react";
+
+import { Button } from "@/components/primitives/button";
+import { cn } from "@/lib/utils";
+import "./ProposalNarrative.css";
+
+export type ProposalNarrativeValue = string | string[];
+
+type NarrativeBlock =
+ | { type: "heading"; level: 2 | 3; text: string }
+ | { type: "ordered-list"; items: string[] }
+ | { type: "unordered-list"; items: string[] }
+ | { type: "quote"; text: string }
+ | { type: "paragraph"; text: string };
+
+const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
+
+function narrativeSource(value: ProposalNarrativeValue): string {
+ return Array.isArray(value) ? value.join("\n\n") : value;
+}
+
+function cleanText(value: string): string {
+ return value.replace(/\r\n?/g, "\n").trim();
+}
+
+function parseNarrative(value: ProposalNarrativeValue): NarrativeBlock[] {
+ const lines = cleanText(narrativeSource(value)).split("\n");
+ const blocks: NarrativeBlock[] = [];
+ let paragraph: string[] = [];
+ let list: {
+ type: "ordered-list" | "unordered-list";
+ items: string[];
+ } | null = null;
+
+ const flushParagraph = () => {
+ const text = paragraph.join(" ").replace(/\s+/g, " ").trim();
+ if (text) blocks.push({ type: "paragraph", text });
+ paragraph = [];
+ };
+ const flushList = () => {
+ if (list && list.items.length > 0) blocks.push(list);
+ list = null;
+ };
+
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+ if (!line) {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line);
+ if (heading) {
+ flushParagraph();
+ flushList();
+ blocks.push({
+ type: "heading",
+ level: heading[1].length >= 3 ? 3 : 2,
+ text: heading[2],
+ });
+ continue;
+ }
+
+ const quote = /^>\s*(.+)$/.exec(line);
+ if (quote) {
+ flushParagraph();
+ flushList();
+ blocks.push({ type: "quote", text: quote[1] });
+ continue;
+ }
+
+ const unordered = /^(?:[-*+])\s+(.+)$/.exec(line);
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(line);
+ if (unordered || ordered) {
+ flushParagraph();
+ const type = ordered ? "ordered-list" : "unordered-list";
+ if (!list || list.type !== type) {
+ flushList();
+ list = { type, items: [] };
+ }
+ list.items.push((ordered ?? unordered)![1]);
+ continue;
+ }
+
+ flushList();
+ paragraph.push(line);
+ }
+
+ flushParagraph();
+ flushList();
+ return blocks;
+}
+
+function safeHref(rawHref: string): string | null {
+ try {
+ const href = new URL(rawHref, "https://vortex.local");
+ return SAFE_LINK_PROTOCOLS.has(href.protocol) ? href.href : null;
+ } catch {
+ return null;
+ }
+}
+
+function NarrativeInline({ text }: { text: string }) {
+ const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^\s)]+\))/g);
+ return (
+ <>
+ {parts.map((part, index) => {
+ if (part.startsWith("`") && part.endsWith("`")) {
+ return (
+
+ {part.slice(1, -1)}
+
+ );
+ }
+ const link = /^\[([^\]]+)\]\(([^\s)]+)\)$/.exec(part);
+ if (link) {
+ const href = safeHref(link[2]);
+ return href ? (
+
+ {link[1]}
+
+ ) : (
+ {link[1]}
+ );
+ }
+ return part;
+ })}
+ >
+ );
+}
+
+export function ProposalNarrative({
+ className,
+ value,
+}: {
+ className?: string;
+ value: ProposalNarrativeValue;
+}) {
+ const blocks = parseNarrative(value);
+ if (blocks.length === 0) return null;
+
+ return (
+
++ ); + } + if (block.type === "ordered-list" || block.type === "unordered-list") { + const Tag = block.type === "ordered-list" ? "ol" : "ul"; + return ( ++
+
{overview}
++ Last saved {new Date(savedAt).toLocaleTimeString()} +
+ ) : null}- Changes autosave locally. Eligible human nodes can save drafts to the - simulation backend (see Drafts). -
-+ Build the proposal in order. Completed steps remain available while + the next requirement stays visible. +
+{description}
++ Continue saved local work or leave it untouched and keep this new + proposal. +
+{description}
+ ) : null} +Total
{budgetTotal.toLocaleString()} HMND
diff --git a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx b/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx
deleted file mode 100644
index 694940b..0000000
--- a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx
+++ /dev/null
@@ -1,768 +0,0 @@
-import { useMemo, useState } from "react";
-import type React from "react";
-import { Label } from "@/components/primitives/label";
-import { Badge } from "@/components/primitives/badge";
-import { Select } from "@/components/primitives/select";
-import { Input } from "@/components/primitives/input";
-import { TierLabel } from "@/components/TierLabel";
-import type { ProposalDraftForm } from "../types";
-import {
- getSystemActionMeta,
- type SystemActionId,
-} from "../templates/systemActions";
-import {
- filterPresetsForEligibility,
- getPresetCategory,
- type ProposalPreset,
-} from "../presets/registry";
-import {
- isTierEligible,
- requiredTierForProposalType,
-} from "@/lib/proposalTypes";
-
-const PROPOSAL_TYPE_OPTIONS: Array<{
- value: ProposalDraftForm["proposalType"];
- label: string;
- helper: string;
-}> = [
- {
- value: "basic",
- label: "Basic",
- helper: "Routine proposals that do not change core system parameters.",
- },
- {
- value: "fee",
- label: "Fee distribution",
- helper: "Adjust fee/treasury allocation rules.",
- },
- {
- value: "monetary",
- label: "Monetary system",
- helper: "Token issuance, emission, or monetary policy changes.",
- },
- {
- value: "core",
- label: "Core infrastructure",
- helper: "Protocol and infrastructure-level changes.",
- },
- {
- value: "administrative",
- label: "Administrative",
- helper: "Governance operations (e.g., chamber lifecycle).",
- },
- {
- value: "dao-core",
- label: "DAO core",
- helper: "Changes to the governance protocol itself.",
- },
-];
-
-export function EssentialsStep(props: {
- attemptedNext: boolean;
- chamberOptions: { value: string; label: string }[];
- draft: ProposalDraftForm;
- initiativeOptions: { value: string; label: string }[];
- setDraft: React.Dispatch
- System changes affect simulation variables directly (e.g., chamber
- creation). Project proposals describe work outside the system.
-
- {hasChosenType
- ? PROPOSAL_TYPE_OPTIONS.find(
- (option) => option.value === draft.proposalType,
- )?.helper
- : "Choose proposal type to continue."}
-
- Required tier:
- {selectedPresetCategory ? (
-
-
- {hasFormationVariants
- ? "Choose Formation (project with milestones) or Policy."
- : "This type has a fixed mode and cannot be switched."}
- Title is required.
- System proposals must target General chamber.
-
- Optional provenance tag. It does not change quorum, voting power, CM,
- MM, chamber membership, or proposal lifecycle rules.
- System change
- {systemActionMeta?.description ??
- "Select a system preset for this type to set an executable action."}
-
- Target chamber id is required.
-
- Target governor address is required.
-
- Title is required for chamber creation.
-
+ {isSystem && kindChosen
+ ? "Change a simulation entity or governance rule through General chamber."
+ : "Propose policy or Formation work in a chamber."}
+
+ {typeChosen
+ ? PROPOSAL_TYPE_OPTIONS.find(
+ (option) => option.value === draft.proposalType,
+ )?.helper
+ : "Choose the governing right used for this proposal."}
+