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 ( +
+ {blocks.map((block, index) => { + const key = `${block.type}-${index}`; + if (block.type === "heading") { + const Tag = block.level === 2 ? "h3" : "h4"; + return ( + + + + ); + } + if (block.type === "quote") { + return ( +
+ +
+ ); + } + if (block.type === "ordered-list" || block.type === "unordered-list") { + const Tag = block.type === "ordered-list" ? "ol" : "ul"; + return ( + + {block.items.map((item, itemIndex) => ( +
  • + +
  • + ))} +
    + ); + } + return ( +

    + +

    + ); + })} +
    + ); +} + +type NarrativeCommand = + | "heading" + | "ordered-list" + | "unordered-list" + | "quote" + | "code" + | "link"; + +const editorCommands: Array<{ command: NarrativeCommand; label: string }> = [ + { command: "heading", label: "Heading" }, + { command: "unordered-list", label: "List" }, + { command: "ordered-list", label: "Numbered list" }, + { command: "quote", label: "Quote" }, + { command: "link", label: "Link" }, + { command: "code", label: "Code" }, +]; + +function commandText(command: NarrativeCommand, selection: string): string { + const content = selection || "Text"; + switch (command) { + case "heading": + return `## ${content}`; + case "unordered-list": + return `- ${content}`; + case "ordered-list": + return `1. ${content}`; + case "quote": + return `> ${content}`; + case "code": + return `\`${content}\``; + case "link": + return `[${content}](https://)`; + } +} + +export function ProposalNarrativeEditor({ + id, + onChange, + placeholder, + rows = 7, + value, +}: { + id: string; + onChange: (value: string) => void; + placeholder: string; + rows?: number; + value: string; +}) { + const textareaRef = useRef(null); + + const applyCommand = (command: NarrativeCommand) => { + const textarea = textareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const inserted = commandText(command, value.slice(start, end)); + const next = `${value.slice(0, start)}${inserted}${value.slice(end)}`; + onChange(next); + window.requestAnimationFrame(() => { + textarea.focus(); + const cursor = start + inserted.length; + textarea.setSelectionRange(cursor, cursor); + }); + }; + + return ( +
    +
    + {editorCommands.map(({ command, label }) => ( + + ))} +
    +