feat(wizard): polished UX with preview pane and review summary
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

User brief: 'recreate dev.flow-master.ai Process Creation Wizard'.
Until now the Wizard wrote EA2 correctly but felt like a form. New:

- Stepper shows checkmarks on past steps (was just step numbers).
  Step labels rewritten in human language: Describe / Steps / Form
  fields / Rules / Review / Done.
- Sticky preview pane on the right shows the live process taking
  shape: name + purpose + step chain (numbered + dispatch-kind
  badges) + form field list + rules list. Collapses to a single
  column at <=1000px.
- Analyze phase: each step gets up/down arrows + remove. New
  'Add step' button.
- Generate phase: clearer placeholder ('e.g. requester name'), Yes/No
  type label.
- Validate phase: friendlier placeholder.
- Review (Draft saved) phase: replaced the 3-number summary card
  with a labelled review-row table that shows process name, purpose,
  step chain, field count, rule count.
- Removed all the 'title=POST /api/...' attribute leaks that exposed
  endpoint paths on hover.
- Helper functions: moveStep / removeStep / addStep keep node order
  intact so the EA2 child-edge chain stays correct.

EA2 write contract unchanged: same 5 phases (Intake / Analyze /
Generate / Validate / Draft saved / Publish), same wizardApi.ops
emit shape, same per-phase apply-batch + governs edge.
This commit is contained in:
2026-06-14 18:03:54 +04:00
parent 7a08b51eec
commit d89660659f
2 changed files with 360 additions and 188 deletions
+76
View File
@@ -2149,3 +2149,79 @@ select.studio-input { background: var(--bp-paper); }
color: var(--bp-navy); cursor: pointer;
}
.approvals-actions-toggle input { cursor: pointer; }
/* Wizard polish — preview pane, stepper checkmarks, review summary, step controls */
.wizard-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 24px;
padding: 0 24px 24px;
}
@media (max-width: 1000px) { .wizard-body { grid-template-columns: 1fr; } }
.wizard-progress { display: flex; gap: 6px; flex-wrap: wrap; }
.wizard-step-marker { display: inline-flex; align-items: center; gap: 6px; font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); letter-spacing: 0.06em; text-transform: uppercase; }
.wizard-step-marker.active { color: var(--bp-navy); font-weight: 700; }
.wizard-step-marker.past { color: color-mix(in srgb, var(--bp-navy) 60%, var(--bp-muted)); }
.step-num {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px;
background: var(--bp-paper); color: var(--bp-navy);
border: 1px solid var(--bp-navy); font-size: 10px; font-weight: 700;
}
.wizard-step-marker.active .step-num { background: var(--bp-amber); color: var(--bp-paper); border-color: var(--bp-amber); }
.step-num-done { background: color-mix(in srgb, #2e7a25 70%, var(--bp-paper)) !important; color: var(--bp-paper) !important; border-color: #2e7a25 !important; }
.step-chevron { color: var(--bp-muted); }
.panel-sub { font-size: 12px; color: var(--bp-muted); line-height: 1.5; margin: -4px 0 12px; }
.node-controls { display: inline-flex; gap: 4px; margin-left: auto; }
.node-controls .icon-btn { padding: 2px 6px; font-size: 12px; }
.node-controls .icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.node-meta { display: flex; align-items: center; gap: 8px; }
.wizard-review {
display: flex; flex-direction: column; gap: 8px;
padding: 14px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
margin-bottom: 12px;
}
.wizard-review-row { display: flex; gap: 12px; font-size: 12px; line-height: 1.5; }
.wizard-review-label {
flex: 0 0 110px;
font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em;
color: var(--bp-muted); text-transform: uppercase;
padding-top: 2px;
}
.wizard-preview {
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
padding: 16px;
display: flex; flex-direction: column; gap: 14px;
position: sticky; top: 60px; align-self: flex-start;
max-height: calc(100vh - 80px); overflow-y: auto;
}
.wizard-preview-head { display: flex; flex-direction: column; gap: 4px; padding-bottom: 10px; border-bottom: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
.wizard-preview-title { font-size: 14px; font-weight: 700; color: var(--bp-navy); }
.wizard-preview-desc { font-size: 11px; color: var(--bp-muted); line-height: 1.4; }
.wizard-preview-section { display: flex; flex-direction: column; gap: 6px; }
.wizard-preview-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); text-transform: uppercase; }
.wizard-preview-chain { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.wizard-preview-chain-item { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 8px; border: 1px solid color-mix(in srgb, var(--bp-navy) 16%, transparent); }
.wizard-preview-chain-idx { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); width: 18px; text-align: right; }
.wizard-preview-chain-name { flex: 1; color: var(--bp-navy); }
.wizard-preview-chain-kind { font-family: var(--bp-mono); font-size: 10px; padding: 1px 6px; border: 1px solid color-mix(in srgb, var(--bp-navy) 30%, transparent); }
.wizard-preview-chain-kind.kind-agent { background: color-mix(in srgb, var(--bp-amber) 25%, var(--bp-paper)); }
.wizard-preview-chain-kind.kind-system { background: color-mix(in srgb, var(--bp-navy) 12%, var(--bp-paper)); }
.wizard-preview-fields, .wizard-preview-rules { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
.wizard-preview-fields li, .wizard-preview-rules li { padding: 4px 8px; border-left: 2px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
.wizard-preview-field-name { font-weight: 600; color: var(--bp-navy); }
.wizard-preview-field-type { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); margin-left: 4px; }
.wizard-preview-empty { color: var(--bp-muted); font-style: italic; font-size: 11px; padding: 4px 0 !important; border-left: 0 !important; }
@media (max-width: 700px) {
.wizard-body { padding: 0 12px 12px; }
.wizard-preview { position: static; max-height: none; }
}
+155 -59
View File
@@ -235,15 +235,27 @@ export default function Wizard() {
}
}
const STEP_LABELS: Record<WizardStep, string> = {
Intake: "Describe",
Analyze: "Steps",
Generate: "Form fields",
Validate: "Rules",
"Draft saved": "Review",
Publish: "Done",
};
const renderStepNav = () => (
<div className="wizard-progress">
{STEPS.map((step, i) => {
const isActive = step === draft.step;
const isPast = STEPS.indexOf(step) < STEPS.indexOf(draft.step);
const isDone = isPast || draft.step === "Publish";
return (
<div key={step} className={`wizard-step-marker ${isActive ? 'active' : ''} ${isPast ? 'past' : ''}`}>
<span className="step-num">{i + 1}</span>
<span className="step-label">{step}</span>
<div key={step} className={`wizard-step-marker ${isActive ? "active" : ""} ${isPast ? "past" : ""}`}>
<span className={`step-num ${isDone ? "step-num-done" : ""}`}>
{isDone ? <Check size={11} /> : i + 1}
</span>
<span className="step-label">{STEP_LABELS[step]}</span>
{i < STEPS.length - 1 && <ChevronRight size={12} className="step-chevron" />}
</div>
);
@@ -251,6 +263,84 @@ export default function Wizard() {
</div>
);
const renderPreviewPane = () => {
if (draft.nodes.length === 0 && draft.fields.length === 0 && draft.rules.length === 0) return null;
return (
<aside className="wizard-preview">
<div className="wizard-preview-head">
<span className="mc-hero-eyebrow">Preview</span>
<div className="wizard-preview-title">{draft.name || "Untitled process"}</div>
{draft.description && <div className="wizard-preview-desc">{draft.description.slice(0, 140)}</div>}
</div>
{draft.nodes.length > 0 && (
<div className="wizard-preview-section">
<div className="wizard-preview-label">Steps · {draft.nodes.length}</div>
<ol className="wizard-preview-chain">
{draft.nodes.map((n, i) => (
<li key={n._key || i} className="wizard-preview-chain-item">
<span className="wizard-preview-chain-idx">{i + 1}</span>
<span className="wizard-preview-chain-name">{n.display_name || "Untitled step"}</span>
<span className={`wizard-preview-chain-kind kind-${n.dispatch_kind || "human"}`}>{n.dispatch_kind || "human"}</span>
</li>
))}
</ol>
</div>
)}
{draft.fields.length > 0 && (
<div className="wizard-preview-section">
<div className="wizard-preview-label">Form · {draft.fields.length} field{draft.fields.length === 1 ? "" : "s"}</div>
<ul className="wizard-preview-fields">
{draft.fields.filter((f) => f.name).map((f, i) => (
<li key={i}>
<span className="wizard-preview-field-name">{f.name}</span>
<span className="wizard-preview-field-type">· {f.type}</span>
</li>
))}
{draft.fields.filter((f) => f.name).length === 0 && (
<li className="wizard-preview-empty">No fields named yet</li>
)}
</ul>
</div>
)}
{draft.rules.length > 0 && (
<div className="wizard-preview-section">
<div className="wizard-preview-label">Rules · {draft.rules.length}</div>
<ul className="wizard-preview-rules">
{draft.rules.filter((r) => r.trim()).map((r, i) => <li key={i}>{r}</li>)}
{draft.rules.filter((r) => r.trim()).length === 0 && (
<li className="wizard-preview-empty">No rules typed yet</li>
)}
</ul>
</div>
)}
</aside>
);
};
const moveStep = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= draft.nodes.length) return;
const next = [...draft.nodes];
[next[i], next[j]] = [next[j], next[i]];
updateDraft({ nodes: next });
};
const removeStep = (i: number) => {
if (draft.nodes.length <= 1) {
pushToast("err", "A process needs at least one step.");
return;
}
updateDraft({ nodes: draft.nodes.filter((_, idx) => idx !== i) });
};
const addStep = () => {
const next = [...draft.nodes, { _key: `n${Date.now()}`, kind: "step", display_name: "New step", dispatch_kind: "human" }];
updateDraft({ nodes: next });
};
return (
<div className="wizard-container">
<header className="wizard-header">
@@ -261,6 +351,7 @@ export default function Wizard() {
{renderStepNav()}
</header>
<div className="wizard-body">
<div className="wizard-content">
{draft.step === "Intake" && (
<div className="wizard-panel">
@@ -287,17 +378,17 @@ export default function Wizard() {
className="btn btn-primary"
onClick={handleIntakeSubmit}
disabled={!draft.description || working || !canPublish}
title="POST /api/ea2/flow"
>
{working ? 'Saving...' : 'Start Drafting →'}
{working ? "Saving..." : "Start drafting →"}
</button>
{!canPublish && <p className="warning-text">Switch to LIVE mode + sign in to create processes.</p>}
{!canPublish && <p className="warning-text">Switch to live mode + sign in to create processes.</p>}
</div>
)}
{draft.step === "Analyze" && (
<div className="wizard-panel">
<h3 className="panel-h">Review proposed structure</h3>
<h3 className="panel-h">What steps does it have?</h3>
<p className="panel-sub">Reorder, rename, or pick who does each step. Order is preserved when we write to EA2.</p>
<div className="node-list">
{draft.nodes.map((n, i) => (
<div key={n._key} className="node-card">
@@ -321,21 +412,22 @@ export default function Wizard() {
updateDraft({ nodes: newNodes });
}}
>
<option value="human">Human Task</option>
<option value="agent">Agent Task</option>
<option value="system">System Task</option>
<option value="human">Human</option>
<option value="agent">Agent</option>
<option value="system">System</option>
</select>
<div className="node-controls">
<button className="icon-btn" onClick={() => moveStep(i, -1)} disabled={i === 0} title="Move up"></button>
<button className="icon-btn" onClick={() => moveStep(i, 1)} disabled={i === draft.nodes.length - 1} title="Move down"></button>
<button className="icon-btn" onClick={() => removeStep(i)} title="Remove"><Close size={12} /></button>
</div>
</div>
</div>
))}
<button className="btn btn-secondary" onClick={addStep}>+ Add step</button>
</div>
<button
className="btn btn-primary"
onClick={handleStructureSave}
disabled={working}
title="POST /api/ea2/apply-batch"
>
{working ? 'Saving...' : 'Confirm Structure →'}
<button className="btn btn-primary" onClick={handleStructureSave} disabled={working}>
{working ? "Saving..." : "Confirm structure →"}
</button>
</div>
)}
@@ -343,11 +435,12 @@ export default function Wizard() {
{draft.step === "Generate" && (
<div className="wizard-panel">
<h3 className="panel-h">What information do you need to collect?</h3>
<p className="panel-sub">These become the form every step asks for. You can add as many as you like.</p>
<div className="field-list">
{draft.fields.map((f, i) => (
<div key={i} className="field-row">
<input
placeholder="Field name"
placeholder="e.g. requester name"
value={f.name}
onChange={e => {
const newFields = [...draft.fields];
@@ -370,27 +463,23 @@ export default function Wizard() {
<button className="icon-btn" onClick={() => updateDraft({ fields: draft.fields.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ fields: [...draft.fields, { name: "", type: "string" }] })}>+ Add Field</button>
<button className="btn btn-secondary" onClick={() => updateDraft({ fields: [...draft.fields, { name: "", type: "string" }] })}>+ Add field</button>
</div>
<button
className="btn btn-primary"
onClick={handleDataSave}
disabled={working}
title="PUT /api/ea2/flow/{key}"
>
{working ? 'Saving...' : 'Confirm Data →'}
<button className="btn btn-primary" onClick={handleDataSave} disabled={working}>
{working ? "Saving..." : "Confirm fields →"}
</button>
</div>
)}
{draft.step === "Validate" && (
<div className="wizard-panel">
<h3 className="panel-h">When should this process do something different? (Rules)</h3>
<h3 className="panel-h">Anything special the process should check?</h3>
<p className="panel-sub">Plain-English rules. We'll attach them as exception checks once published.</p>
<div className="field-list">
{draft.rules.map((r, i) => (
<div key={i} className="field-row">
<input
placeholder="e.g. If cost > 5000, require VP approval"
placeholder="e.g. If cost is over 5000, require VP approval"
value={r}
onChange={e => {
const newRules = [...draft.rules];
@@ -402,37 +491,46 @@ export default function Wizard() {
<button className="icon-btn" onClick={() => updateDraft({ rules: draft.rules.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ rules: [...draft.rules, ""] })}>+ Add Rule</button>
<button className="btn btn-secondary" onClick={() => updateDraft({ rules: [...draft.rules, ""] })}>+ Add rule</button>
</div>
<button
className="btn btn-primary"
onClick={handleRulesSave}
disabled={working}
title="PUT /api/ea2/flow/{key}"
>
{working ? 'Saving...' : 'Review Process →'}
<button className="btn btn-primary" onClick={handleRulesSave} disabled={working}>
{working ? "Saving..." : "Review process →"}
</button>
</div>
)}
{draft.step === "Draft saved" && (
<div className="wizard-panel">
<h3 className="panel-h">Review & Publish</h3>
<p>Your process <strong>{draft.name || 'Untitled'}</strong> is ready to be published to EA2.</p>
<div className="summary-stats">
<div><span>Steps:</span> {draft.nodes.length}</div>
<div><span>Data fields:</span> {draft.fields.length}</div>
<div><span>Rules:</span> {draft.rules.length}</div>
<h3 className="panel-h">Review &amp; publish</h3>
<p className="panel-sub">Last look. Hit publish to write the definition + steps + form + version to EA2 in one atomic batch.</p>
<div className="wizard-review">
<div className="wizard-review-row">
<span className="wizard-review-label">Process</span>
<strong>{draft.name || "Untitled"}</strong>
</div>
{draft.description && (
<div className="wizard-review-row">
<span className="wizard-review-label">Purpose</span>
<span>{draft.description}</span>
</div>
)}
<div className="wizard-review-row">
<span className="wizard-review-label">Steps</span>
<span>{draft.nodes.length} ({draft.nodes.map((n) => n.display_name || "Untitled").join(" → ")})</span>
</div>
<div className="wizard-review-row">
<span className="wizard-review-label">Form fields</span>
<span>{draft.fields.filter((f) => f.name).length || "none"}</span>
</div>
<div className="wizard-review-row">
<span className="wizard-review-label">Rules</span>
<span>{draft.rules.filter((r) => r.trim()).length || "none"}</span>
</div>
</div>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => updateDraft({ step: "Intake" })}>Edit</button>
<button
className="btn btn-primary"
onClick={handlePublish}
disabled={working}
title="POST /api/ea2/apply-batch"
>
{working ? 'Publishing...' : 'Publish to EA2'}
<button className="btn btn-secondary" onClick={() => updateDraft({ step: "Intake" })}>Back to edit</button>
<button className="btn btn-primary" onClick={handlePublish} disabled={working}>
{working ? "Publishing..." : "Publish to EA2"}
</button>
</div>
</div>
@@ -441,22 +539,20 @@ export default function Wizard() {
{draft.step === "Publish" && (
<div className="wizard-panel success-panel">
<div className="success-icon"><Check size={32} /></div>
<h3 className="panel-h">Process Published!</h3>
<h3 className="panel-h">Process published</h3>
<p>Your process <strong>{draft.name || "Untitled"}</strong> is now active and ready to run.</p>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => { setDraft({ step: "Intake", name: "", description: "", nodes: [], edges: [], fields: [], rules: [] }); setPublishedKey(null); }}>Create Another</button>
<button
className="btn btn-primary"
onClick={handleStartInstance}
disabled={working}
title="POST /api/runtime/transactions"
>
Start First Instance Now
<button className="btn btn-secondary" onClick={() => { setDraft({ step: "Intake", name: "", description: "", nodes: [], edges: [], fields: [], rules: [] }); setPublishedKey(null); }}>Create another</button>
<button className="btn btn-primary" onClick={handleStartInstance} disabled={working}>
Start first instance
</button>
</div>
</div>
)}
</div>
{renderPreviewPane()}
</div>
</div>
);
}