Mission Control demo v2

Polished command-center for FlowMaster with two data modes:
- SNAPSHOT: bundled src/scenarios.json from demo.flow-master.ai
- LIVE: in-browser fetch via src/lib/api.ts (dev-login + bearer)

Scenarios:
- procurement, extra-1, extra-2 (live from EA2)
- ar, hcm, gl, service (industry blueprints, same typed shell)

Honesty pass after Oracle review:
- No invented numbers (Telemetry derives SLA + agent acceptance from real data)
- Preview-only actions fire toasts naming the endpoint to wire them
- Blueprint tours framed as 'industry blueprint', not 'we don't have this yet'
- Mode pill + last-fetch age + refresh in topbar
- Dev CORS dodged via vite proxy; production deploys same-origin

18 vitest tests + 26 playwright smoke assertions + DOM layout audit.

Constraint: cross-origin live mode rejected by browser → fall back to snapshot
Rejected: hardcoded SLA % | dishonest demo metrics
Directive: wire preview-only action handlers to /api/runtime/transactions/{id}/actions to ship them for real
Confidence: high
Scope-risk: narrow
Not-tested: production deployment via flowmaster-ops overlay
This commit is contained in:
Shad 2026-06-14 00:09:32 +04:00
commit 3ffd0e68a7
45 changed files with 15603 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Build
node_modules
dist
dist-ssr
*.local
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
# QA artifacts (regenerated)
qa/screenshots/
# TypeScript build info
*.tsbuildinfo

152
README.md Normal file
View File

@ -0,0 +1,152 @@
# FlowMaster — Mission Control demo
A polished, presenter-ready command-center for FlowMaster. Lives at
[`shad/flowmaster-mission-control-demo`](https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo)
on Gitea.
## What this is (and isn't)
**Is:**
- A single-page React 19 app (Vite + ReactFlow + cmdk + framer-motion + zustand
+ dagre).
- Polished command-center for any FlowMaster process: graph, queue, inspector,
tour, command palette, live-mode toggle, run history.
- Two data modes:
- **SNAPSHOT** (default): bundled `src/scenarios.json`, captured from
`demo.flow-master.ai`. Fast, offline, deterministic.
- **LIVE**: in-browser fetch from the same backend via the API client at
`src/lib/api.ts` (dev-login → bearer → `/api/ea2/work-items`
`/api/ea2/process-definitions/{k}/graph``/api/runtime/transactions/{id}`).
Loading and error states are wired through `src/scenes/MissionControl.tsx`.
**Isn't:**
- Not a replacement for the existing demo at https://demo.flow-master.ai
(Next.js fm-shell). This is a separate command-center experience.
- Not multi-tenant. No auth UI, no tenancy switcher, no settings.
- Not wired to actually mutate state. Every action button (start runtime,
dispatch agent, approve, decline, confirm) is **preview-only** and fires a
toast naming the endpoint that would make it real.
## Scenarios in the catalog
| id | mode | source |
| ----------- | --------- | ------------------------------------------------------------------- |
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
| extra-1 | live | Atlas F1 Fresh (procurement variant) |
| extra-2 | live | Atlas F1 Fresh (procurement variant) |
| ar | blueprint | AR · Customer Refund Approval |
| hcm | blueprint | HCM · New Hire Onboarding |
| gl | blueprint | GL · Period-End Close |
| service | blueprint | Service Ops · Customer Incident |
**Live** = backed by a real EA2 process definition currently in the demo
backend, with real runtime transactions and a real work-item queue.
**Blueprint** = hand-modelled in the same typed format. Identical UI surface;
the backend just doesn't have a runnable definition for it yet. Internal
metadata carries `isSynthetic: true` for provenance audits.
## How honesty is enforced in this code
This pass came out of an Oracle review that called out theatrical bits in the
prior version. Safeguards now in place:
- **No invented numbers.** `src/components/Telemetry.tsx` derives every value
from the active scenarios (`SLA = 1 - errored / cases`, agent acceptance =
fraction of agent runs not in `proposed`). The throughput sparkline plots
the actual `running` rollup over time, not a sine wave. The "ui tick" dot
is labelled as a UI heartbeat and tooltip-named as such.
- **No fake buttons.** Every preview-only action fires a toast that names the
endpoint that would make it real, and carries a `preview` marker.
- **No misleading tour copy.** Blueprint tours frame the scenario as an
industry blueprint, not "we don't have this yet".
- **Mode is always visible.** Topbar has a `SNAPSHOT`/`LIVE` pill, last-fetch
age, and a refresh button when live.
## Live-mode mechanics (the CORS gotcha)
`demo.flow-master.ai` does not advertise CORS headers for arbitrary origins.
- In **dev** (`pnpm dev`): Vite proxies `/api/*` to
`${VITE_FM_BASE:-https://demo.flow-master.ai}` (see `vite.config.ts`). The
browser sees a same-origin request, no CORS check. `src/lib/api.ts` uses an
empty `baseUrl` in dev for this.
- In **production**: deploy the build at the same origin as the backend (or
behind a reverse proxy that passes `/api/*` through). Cross-origin
deployments fail gracefully — `setMode("live")` catches the error, surfaces
it in the topbar banner and a toast, and falls back to snapshot mode.
## Run, test, build
```bash
pnpm install
# refresh the bundled snapshot from demo.flow-master.ai
pnpm fetch:scenarios
# dev (with backend proxy for live mode)
pnpm dev # → http://127.0.0.1:5173
# tests
pnpm test # vitest, 18 tests across api, live,
# synthetic, layout
# build
pnpm build # tsc + vite, single chunk ~225 KB gz
# end-to-end smoke + screenshots
pnpm qa:smoke # playwright headless, 26 assertions
# DOM layout audit
pnpm qa:layout
```
## File map
```
src/
├── data/ # ProcessScenario domain + snapshot + blueprint catalog
├── lib/ # API client + live-scenario builder (+ tests)
├── state/store.ts # zustand: scene, mode, scenario, tour, recents, toasts
├── graph/layout.ts # dagre LR auto-layout (+ tests)
├── components/ # ProcessGraph, Inspector, LeftRail, CommandBar, Tour,
│ # Telemetry, Toaster, icons
├── scenes/ # Landing, MissionControl, RunHistory
├── App.tsx # shell: topbar (mode pill / refresh / tour / ⌘K)
├── index.css # design system (~700 lines)
├── main.tsx
└── scenarios.json # cached snapshot of demo.flow-master.ai
qa/
├── smoke.mjs # Playwright e2e + 9 screenshots
└── layout_audit.mjs # programmatic clipping/overlap check
vite.config.ts # /api → demo.flow-master.ai proxy for dev
fetch_scenarios.mjs # Node script to refresh src/scenarios.json
```
## Deploy
Production deployment is tracked in `FM06/flowmaster-ops` under
`manifests/overlays/mc.flow-master.ai/`. A static nginx serves `dist/`;
ingress fronts it at `mc.flow-master.ai`. `/api/*` is reverse-proxied to the
same backend `demo.flow-master.ai` talks to — that's what makes live mode
work in production without CORS.
```bash
pnpm build
# dist/ is the static site → ship to nginx referenced by the ops overlay
```
## What's intentionally not here
- Authentication UI (dev-login is used because this is a demo lane).
- Mutation endpoints (every action is preview-only and the toast names the
endpoint).
- Mobile layout (1440px-and-up demo surface).
- Route persistence / deep linking (scene + scenario live in memory).
- i18n (English only).
Small, well-scoped follow-ups — not architectural changes.

22
eslint.config.js Normal file
View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

200
fetch_scenarios.mjs Normal file
View File

@ -0,0 +1,200 @@
// Multi-scenario live read from demo.flow-master.ai.
//
// Strategy:
// 1. dev-login → bearer token
// 2. /api/ea2/work-items?view=all → enumerate every definition_key
// actually referenced by live work, then count cases per definition.
// 3. For each top-N definition, fetch its graph (`/api/ea2/process-definitions/{k}/graph`)
// and a few full runtime transactions.
// 4. Classify into family buckets (procurement/AR/HCM/GL close/service ops)
// using definition display_name + node labels.
// 5. Write src/scenarios.json with the picked scenarios + the raw work-item
// board so the UI can show a real "All work" view.
//
// READ-ONLY. Run: `node fetch_scenarios.mjs`.
import { writeFileSync } from "node:fs";
const BASE = process.env.FM_BASE || "https://demo.flow-master.ai";
const EMAIL = process.env.FM_EMAIL || "dev@flow-master.ai";
const login = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL }),
});
if (!login.ok) throw new Error(`dev-login ${login.status}`);
const token = (await login.json()).access_token;
const H = { Authorization: `Bearer ${token}` };
const tryGet = async (path) => {
try {
const r = await fetch(`${BASE}${path}`, { headers: H });
if (!r.ok) return null;
return await r.json();
} catch {
return null;
}
};
// 1. Pull the full live work-item board.
const board = await tryGet("/api/ea2/work-items?view=all");
const workItems = board?.items || [];
console.log(`${workItems.length} live work-items`);
// 2. Enumerate unique definition_keys + per-def metadata.
const byDef = new Map();
for (const w of workItems) {
const k = w.definition_key;
if (!k) continue;
if (!byDef.has(k)) {
byDef.set(k, { key: k, hubs: new Set(), cases: [], statuses: {} });
}
const r = byDef.get(k);
if (w.hub) r.hubs.add(w.hub);
r.cases.push(w);
r.statuses[w.status] = (r.statuses[w.status] || 0) + 1;
}
console.log(`${byDef.size} distinct definitions in work board`);
// 3. Filter to meaningful definitions (≥1 running OR ≥2 total cases).
const candidates = [...byDef.values()].filter((d) => {
const total = d.cases.length;
const running = (d.statuses.running || 0) + (d.statuses.waiting_for_user || 0);
return total >= 2 || running >= 1;
});
candidates.sort((a, b) => b.cases.length - a.cases.length);
console.log(`${candidates.length} candidates after filter`);
// 4. Fetch graph + representative transaction per candidate.
const enriched = [];
for (const c of candidates.slice(0, 20)) {
const graph = await tryGet(`/api/ea2/process-definitions/${c.key}/graph`);
if (!graph?.process_definition?.config?.nodes?.length) {
console.warn(` ! ${c.key.slice(0, 8)} no graph`);
continue;
}
const headlineCase =
c.cases.find((w) => w.status === "running") ||
c.cases.find((w) => w.status === "waiting_for_user") ||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
c.cases[0];
let headlineRt = null;
if (headlineCase?.transaction_id) {
headlineRt = await tryGet(`/api/runtime/transactions/${headlineCase.transaction_id}`);
}
const recent = [];
for (const w of c.cases.slice(0, 6)) {
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
const r = await tryGet(`/api/runtime/transactions/${w.transaction_id}`);
if (r) recent.push(r);
if (recent.length >= 3) break;
}
enriched.push({
key: c.key,
name: graph.process_definition.display_name || graph.process_definition.name,
hubs: [...c.hubs],
statuses: c.statuses,
cases: c.cases,
graph,
headlineCase,
headlineRt,
recent,
});
console.log(
`${c.key.slice(0, 8)} ${(graph.process_definition.display_name || "—").slice(0, 36)} nodes=${graph.process_definition.config.nodes.length} cases=${c.cases.length} rt=${headlineRt ? "live" : "no"}`
);
}
// 5. Classify into families.
const families = [
{
id: "procurement", label: "Procurement to Pay", subtitle: "Requisition → PO → 3-way match",
accent: "#3b82f6",
match: (e) => /procure|purchas|pr_to_po|atlas|requisition|po\b/i.test(`${e.name} ${e.hubs.join(" ")}`),
},
{
id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections",
accent: "#10b981",
match: (e) => /refund|credit|collect|receivable|ar\b|invoice/i.test(e.name),
},
{
id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave",
accent: "#a855f7",
match: (e) => /onboard|offboard|hire|hcm|employee|leave|payroll|hr\b/i.test(e.name),
},
{
id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals",
accent: "#f59e0b",
match: (e) => /close|ledger|journal|accrual|reconcil|gl\b/i.test(e.name),
},
{
id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support",
accent: "#ef4444",
match: (e) => /ticket|incident|support|service|case\b/i.test(e.name),
},
];
const scenarios = [];
const used = new Set();
for (const fam of families) {
const pick = enriched
.filter((e) => !used.has(e.key) && fam.match(e))
.sort((a, b) => b.cases.length - a.cases.length)[0];
if (!pick) {
console.log(` ⚠ family ${fam.id} unmatched`);
continue;
}
used.add(pick.key);
scenarios.push({ family: fam, ...pick });
}
// Top up to at least 4 with largest remaining.
const remaining = enriched.filter((e) => !used.has(e.key));
remaining.sort((a, b) => b.cases.length - a.cases.length);
while (scenarios.length < 4 && remaining.length) {
const e = remaining.shift();
scenarios.push({
family: {
id: `extra-${scenarios.length}`,
label: e.name || "Process",
subtitle: `${e.cases.length} live cases`,
accent: "#64748b",
match: () => false,
},
...e,
});
used.add(e.key);
}
console.log(
`\n${scenarios.length} scenarios: ` +
scenarios.map((s) => `${s.family.id}:${s.name?.slice(0, 24)}`).join(" | ")
);
// 6. Write artifact.
const out = {
fetchedFrom: BASE,
fetchedAt: new Date().toISOString(),
totals: {
workItems: workItems.length,
distinctDefs: byDef.size,
scenarios: scenarios.length,
},
board: workItems,
scenarios: scenarios.map((s) => ({
id: s.family.id,
family: s.family,
def_key: s.key,
def_name: s.name,
hubs: s.hubs,
statuses: s.statuses,
cases: s.cases,
graph: s.graph,
headlineTx: s.headlineCase?.transaction_id || null,
headlineRt: s.headlineRt,
recent: s.recent,
})),
};
writeFileSync(new URL("./src/scenarios.json", import.meta.url), JSON.stringify(out, null, 2));
console.log(`\n✓ wrote src/scenarios.json (${scenarios.length} scenarios, ${workItems.length} board items)`);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fm-command-center-spike</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "flowmaster-mission-control-demo",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"fetch:scenarios": "node fetch_scenarios.mjs",
"qa:smoke": "node qa/smoke.mjs",
"qa:layout": "node qa/layout_audit.mjs"
},
"dependencies": {
"@types/dagre": "^0.7.54",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"framer-motion": "^12.40.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"reactflow": "^11.11.4",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.60.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.8"
}
}

2998
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

93
qa/layout_audit.mjs Normal file
View File

@ -0,0 +1,93 @@
// Programmatic visual-QA: walks the rendered DOM and reports likely
// layout defects (overflow, clipping, overlap, zero-size, low contrast).
import { chromium } from "playwright";
const URL = process.env.URL || "http://127.0.0.1:5173";
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const findings = [];
function add(kind, msg) { findings.push({ kind, msg }); }
await page.goto(URL, { waitUntil: "networkidle" });
async function auditScene(label, prep) {
await prep();
await page.waitForTimeout(500);
// For each candidate selector, check for overflow / hidden / zero size
const results = await page.evaluate(() => {
const out = [];
const sels = [
".hero-title", ".hero-sub", ".sc-card-title", ".sc-card-sub",
".mc-hero-title", ".mc-hero-sub", ".mc-tab-label",
".node-name", ".panel-h", ".qcard-title", ".inspector-title h3",
".tour-title", ".tour-body", ".telemetry .t-block", ".rh-row-step",
".graph-overlay-name",
];
for (const sel of sels) {
for (const el of Array.from(document.querySelectorAll(sel))) {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const clipped = el.scrollWidth > r.width + 1 && cs.overflow !== "visible" && cs.textOverflow === "ellipsis";
const overflowH = el.scrollHeight > r.height + 1 && cs.overflow !== "visible" && cs.overflowY !== "auto" && cs.overflowY !== "scroll";
const offscreen = r.right < 0 || r.left > window.innerWidth || r.bottom < 0 || r.top > window.innerHeight + 1;
const zero = (r.width < 2 || r.height < 2) && el.textContent && el.textContent.trim().length > 0;
if (clipped || overflowH || offscreen || zero) {
out.push({
sel, text: (el.textContent || "").trim().slice(0, 80),
w: r.width.toFixed(0), h: r.height.toFixed(0),
sw: el.scrollWidth, sh: el.scrollHeight,
issues: { clipped, overflowH, offscreen, zero },
});
}
}
}
return out;
});
for (const r of results) {
add("layout", `[${label}] ${r.sel}: "${r.text}" (${r.w}x${r.h} content ${r.sw}x${r.sh}) ${JSON.stringify(r.issues)}`);
}
}
await auditScene("landing", async () => {});
await auditScene("mission-1st-live", async () => {
await page.locator(".sc-card").first().click();
await page.waitForSelector(".mc");
});
await auditScene("mission-ar", async () => {
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
});
await auditScene("inspector-raw", async () => {
await page.locator(".itab", { hasText: "Raw" }).click();
});
await auditScene("tour", async () => {
await page.locator(".link-btn", { hasText: "Tour" }).click();
await page.waitForSelector(".tour-card");
});
await auditScene("history", async () => {
await page.keyboard.press("Escape");
await page.waitForTimeout(100);
await page.locator(".tab", { hasText: "Runs" }).click();
await page.waitForSelector(".rh");
});
console.log(`findings: ${findings.length}`);
for (const f of findings.slice(0, 50)) console.log(` · ${f.msg}`);
// Also dump key layout dimensions for sanity
const dims = await page.evaluate(() => {
const get = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { sel, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top), left: Math.round(r.left) };
};
return [
get(".rh-head"),
get(".rh-list"),
get(".rh-row"),
];
});
console.log("\nrh dimensions:", JSON.stringify(dims, null, 2));
await browser.close();

155
qa/smoke.mjs Normal file
View File

@ -0,0 +1,155 @@
// Playwright smoke + screenshot capture against the running dev server.
// Covers: landing, MC procurement, MC blueprint (AR), inspector tabs,
// command palette, tour, run history, live-mode toggle, toast on preview action.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const URL = process.env.URL || "http://127.0.0.1:5173";
const OUT = "qa/screenshots";
mkdirSync(OUT, { recursive: true });
const VP = { width: 1440, height: 900 };
function logOk(label, ok, detail = "") {
console.log(`${ok ? "✓" : "✗"} ${label}${detail ? " · " + detail : ""}`);
if (!ok) process.exitCode = 1;
}
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: VP, deviceScaleFactor: 1 });
const page = await ctx.newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
page.on("console", (m) => { if (m.type() === "error") errors.push(`console.error: ${m.text()}`); });
await page.goto(URL, { waitUntil: "networkidle" });
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/01-landing.png` });
logOk("landing renders", await page.locator(".landing").isVisible());
logOk("hero title present", await page.locator(".hero-title").isVisible());
const cards = await page.locator(".sc-card").count();
logOk("scenario cards >= 7", cards >= 7, `count=${cards}`);
logOk("Go live button visible on landing", await page.getByRole("button", { name: /go live/i }).isVisible());
// → Mission Control via first card (live procurement)
await page.locator(".sc-card").first().click();
await page.waitForSelector(".mc");
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/02-mission-procurement.png` });
logOk("mission control loaded", await page.locator(".mc-strip").isVisible());
// Topbar mode pill is SNAPSHOT by default
const modeBefore = (await page.locator(".mode-toggle .mode-pill").innerText()).trim();
logOk("default mode is SNAPSHOT", modeBefore === "SNAPSHOT", `was=${modeBefore}`);
// Tab strip + graph
const tabCount = await page.locator(".mc-tab").count();
logOk("tab strip has >= 7 scenarios", tabCount >= 7, `count=${tabCount}`);
const reactFlowNodes = await page.locator(".node").count();
logOk("graph rendered nodes > 0", reactFlowNodes > 0, `nodes=${reactFlowNodes}`);
// Switch to AR blueprint
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/03-mission-ar.png` });
logOk("AR scenario active", (await page.locator(".mc-hero-title").innerText()).toLowerCase().includes("refund"));
logOk("AR shows BLUEPRINT badge (not SYNTHETIC)", await page.locator(".graph-overlay .tag-syn", { hasText: "BLUEPRINT" }).isVisible());
// Queue card → inspector update
const qCards = await page.locator(".qcard").count();
logOk("queue cards present", qCards > 0, `count=${qCards}`);
if (qCards > 0) {
await page.locator(".qcard").first().click();
await page.waitForTimeout(200);
}
// Inspector tab switching
await page.locator(".itab", { hasText: "Evidence" }).click();
await page.waitForTimeout(150);
logOk("evidence tab opens", await page.locator(".evt, .empty").first().isVisible());
await page.locator(".itab", { hasText: "Raw" }).click();
await page.waitForTimeout(150);
logOk("raw tab opens", await page.locator(".raw-json").isVisible());
await page.screenshot({ path: `${OUT}/04-inspector-raw.png` });
// Preview-only action toast
await page.locator(".itab", { hasText: "Overview" }).click();
await page.waitForTimeout(150);
const approveBtn = page.locator(".i-actions .btn", { hasText: "Approve" }).first();
const approveCount = await approveBtn.count();
if (approveCount > 0) {
await approveBtn.click();
await page.waitForTimeout(300);
const toastTxt = (await page.locator(".toast-msg").first().innerText()).trim();
logOk("preview action fires toast", /preview[- ]only/i.test(toastTxt), `toast="${toastTxt.slice(0, 80)}"`);
} else {
logOk("approve button preview-marker present", await page.locator(".preview-marker").first().isVisible());
}
// Command palette
await page.keyboard.press("Meta+k");
await page.waitForSelector(".cmd", { state: "visible" });
await page.waitForTimeout(150);
await page.screenshot({ path: `${OUT}/05-command-palette.png` });
logOk("command palette opens with ⌘K", await page.locator(".cmd").isVisible());
logOk("Data mode group present in palette", await page.locator(".cmd").getByText(/data mode/i).isVisible());
await page.locator('.cmd [cmdk-input]').fill("tour");
await page.waitForTimeout(150);
logOk("tour command appears", await page.locator(".cmd").getByText(/start guided tour/i).isVisible());
await page.keyboard.press("Escape");
await page.waitForTimeout(100);
// Start tour
await page.locator(".link-btn", { hasText: "Tour" }).click();
await page.waitForSelector(".tour-card");
await page.waitForTimeout(250);
await page.screenshot({ path: `${OUT}/06-tour.png` });
logOk("tour card renders", await page.locator(".tour-card").isVisible());
const firstTourCombined = ((await page.locator(".tour-title").first().innerText()) + " " + (await page.locator(".tour-body").first().innerText())).toLowerCase();
logOk("AR tour uses positive 'industry blueprint' framing", /industry blueprint/i.test(firstTourCombined), `text~="${firstTourCombined.slice(0, 100)}"`);
await page.keyboard.press("ArrowRight");
await page.waitForTimeout(450);
logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0);
await page.keyboard.press("Escape");
// Live-mode toggle (real network call to demo.flow-master.ai)
const toggleBtn = page.locator(".mode-toggle").first();
await toggleBtn.click();
const liveOk = await page.waitForFunction(
() => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"),
{ timeout: 12000 },
).then(() => true).catch(() => false);
logOk("toggle to LIVE mode resolves and updates pill", liveOk);
if (liveOk) {
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/09-live-mode.png` });
logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: "Refresh" }).isVisible());
}
// Telemetry honesty (check before leaving MC because RH has no telemetry strip)
const tel = await page.locator(".telemetry").innerText();
logOk("telemetry has no hardcoded 97.4%", !/97\.4%/.test(tel), `tel="${tel.replace(/\s+/g, " ").slice(0, 80)}"`);
logOk("telemetry labels SLA as derived", /derived/i.test(tel));
// Run history scene
await page.locator(".tab", { hasText: "Runs" }).click();
await page.waitForSelector(".rh");
await page.waitForTimeout(250);
await page.screenshot({ path: `${OUT}/07-run-history.png` });
const rhRows = await page.locator(".rh-row").count();
logOk("run-history rows present", rhRows > 0, `rows=${rhRows}`);
await page.locator(".rh-chip", { hasText: "running" }).click();
await page.waitForTimeout(200);
const rhRunning = await page.locator(".rh-row").count();
logOk("run-history filter shrinks list", rhRunning > 0 && rhRunning <= rhRows, `running=${rhRunning} of ${rhRows}`);
console.log(`\nconsole errors: ${errors.length}`);
for (const e of errors.slice(0, 8)) console.log(" -", e);
if (errors.length > 0) process.exitCode = 1;
await browser.close();
const fs = await import("node:fs/promises");
const list = await fs.readdir(OUT);
console.log(`\n${OUT}/ has ${list.length} screenshots: ${list.sort().join(", ")}`);

108
src/App.tsx Normal file
View File

@ -0,0 +1,108 @@
// App shell — scene switcher + top bar + command palette + tour overlay.
import { useApp, scenarioById } from "./state/store";
import Landing from "./scenes/Landing";
import MissionControl from "./scenes/MissionControl";
import RunHistory from "./scenes/RunHistory";
import CommandBar from "./components/CommandBar";
import Tour from "./components/Tour";
import Toaster from "./components/Toaster";
import { Cmd, Home, Layers, History as HistoryIcon, Sparkles, Pulse, Refresh } from "./components/icons";
import { liveMeta } from "./data/scenarios";
export default function App() {
const scene = useApp((s) => s.scene);
const setScene = useApp((s) => s.setScene);
const setCmdOpen = useApp((s) => s.setCmdOpen);
const scenarioId = useApp((s) => s.scenarioId);
const sc = scenarioById(scenarioId);
const startTour = useApp((s) => s.startTour);
const tourActive = useApp((s) => s.tour.active);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const liveLoading = useApp((s) => s.liveLoading);
const liveFetchedAt = useApp((s) => s.liveFetchedAt);
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
return (
<div className={`shell shell-${scene}`}>
{scene !== "landing" && (
<header className="topbar" data-anchor="topbar">
<button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home">
<span className="brand-mark sm" />
<span className="brand-name">FlowMaster</span>
<span className="brand-divider" />
<span className="brand-sub">Mission Control</span>
</button>
<nav className="tabs" role="tablist">
<button role="tab" aria-selected={scene === "mission"} className={`tab${scene === "mission" ? " tab-sel" : ""}`} onClick={() => setScene("mission")}>
<Layers size={13} /> Mission
</button>
<button role="tab" aria-selected={scene === "history"} className={`tab${scene === "history" ? " tab-sel" : ""}`} onClick={() => setScene("history")}>
<HistoryIcon size={13} /> Runs
</button>
<button role="tab" aria-selected={false} className="tab" onClick={() => setScene("landing")}>
<Home size={13} /> Home
</button>
</nav>
<div className="topbar-mid">
{sc && (
<div className="topbar-context">
<span className="topbar-chip">
<span className="dot dot-running" /> {sc.family.label}
</span>
<span className="topbar-chip">
<span className="mono">{sc.defName.slice(0, 40)}</span>
</span>
<span className="topbar-chip mono">{sc.version}</span>
{sc.live ? (
<span className="tag tag-live">live · {liveMeta.fetchedFrom?.replace("https://", "")}</span>
) : (
<span className="tag tag-syn">blueprint</span>
)}
</div>
)}
</div>
<div className="topbar-actions">
<button
className={`link-btn mode-toggle mode-${mode}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
disabled={liveLoading}
title={mode === "live"
? `Live mode is on. Fetched ${liveAge}. Click to drop back to snapshot.`
: "Click to fetch live scenarios from demo.flow-master.ai in the browser."}
>
{liveLoading ? <span className="spin" /> : <Pulse size={12} />}
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
</button>
{mode === "live" && (
<button className="link-btn" onClick={() => setMode("live")} disabled={liveLoading} title="Re-fetch live scenarios">
<Refresh size={12} /> Refresh
</button>
)}
<button className="link-btn" onClick={startTour} disabled={tourActive}>
<Sparkles size={13} /> Tour
</button>
<button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> Command <kbd>K</kbd>
</button>
</div>
</header>
)}
<div className="scene">
{scene === "landing" && <Landing />}
{scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />}
</div>
<CommandBar />
<Tour />
<Toaster />
</div>
);
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,140 @@
// Command palette v2 — scenarios, steps, tour, scenes, recents.
import { useEffect, useMemo } from "react";
import { Command } from "cmdk";
import { useApp, scenarioById } from "../state/store";
import { Play, Branch, Bot, Check, Sparkles, Home, History as HistoryIcon, Layers, Search, Refresh } from "./icons";
export default function CommandBar() {
const open = useApp((s) => s.cmdOpen);
const setOpen = useApp((s) => s.setCmdOpen);
const setScenarioId = useApp((s) => s.setScenarioId);
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
const startTour = useApp((s) => s.startTour);
const setScene = useApp((s) => s.setScene);
const recents = useApp((s) => s.recents);
const scenarioId = useApp((s) => s.scenarioId);
const pushRecent = useApp((s) => s.pushRecent);
const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const pushToast = useApp((s) => s.pushToast);
const sc = scenarioById(scenarioId);
const previewAction = (label: string) => {
pushToast("info", `${label} is preview-only in this demo. Wire to /api/runtime/transactions to make it real.`);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(!open);
}
if (e.key === "Escape" && open) setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, setOpen]);
const stepItems = useMemo(() => sc?.steps ?? [], [sc]);
if (!open) return null;
const close = () => setOpen(false);
return (
<div className="cmd-overlay" onClick={close}>
<Command className="cmd" onClick={(e) => e.stopPropagation()} label="Command bar">
<div className="cmd-input-row">
<Search size={14} />
<Command.Input autoFocus placeholder="Switch scenario · jump to step · start tour…" />
<kbd className="kbd-hint">esc</kbd>
</div>
<Command.List>
<Command.Empty>No matches.</Command.Empty>
{recents.length > 0 && (
<Command.Group heading="Recent">
{recents.map((r, i) => (
<Command.Item key={`r-${i}-${r}`} onSelect={() => { pushRecent(r); close(); }}>
<HistoryIcon size={13} /> {r}
</Command.Item>
))}
</Command.Group>
)}
<Command.Group heading="Scenes">
<Command.Item onSelect={() => { setScene("landing"); close(); }}>
<Home size={13} /> Landing
</Command.Item>
<Command.Item onSelect={() => { setScene("mission"); close(); }}>
<Layers size={13} /> Mission Control
</Command.Item>
<Command.Item onSelect={() => { setScene("history"); close(); }}>
<HistoryIcon size={13} /> Run History
</Command.Item>
</Command.Group>
<Command.Group heading="Tour & demo">
<Command.Item onSelect={() => { startTour(); close(); }}>
<Sparkles size={13} /> Start guided tour <span className="cmd-hint">presenter mode</span>
</Command.Item>
</Command.Group>
<Command.Group heading="Scenarios">
{scenarios.map((s) => (
<Command.Item
key={s.id}
value={`scenario ${s.id} ${s.family.label} ${s.defName}`}
onSelect={() => { setScenarioId(s.id); setScene("mission"); close(); }}
>
<Branch size={13} style={{ color: s.family.accent }} />
{s.family.label}
<span className="cmd-hint">{s.live ? "live" : "blueprint"} · {s.defName}</span>
</Command.Item>
))}
</Command.Group>
{sc && (
<Command.Group heading="Jump to step">
{stepItems.map((st) => (
<Command.Item
key={st.id}
value={`step ${st.id} ${st.name} ${st.kind}`}
onSelect={() => { setSelectedStepId(st.id); setScene("mission"); close(); }}
>
<span className={`dot dot-${st.state}`} aria-hidden /> {st.name}
<span className="cmd-hint mono">{st.kind}</span>
</Command.Item>
))}
</Command.Group>
)}
<Command.Group heading="Data mode">
<Command.Item onSelect={() => { setMode("live"); close(); }}>
<Refresh size={13} /> {mode === "live" ? "Refresh live scenarios" : "Switch to LIVE mode (fetch demo.flow-master.ai)"}
<span className="cmd-hint">{mode === "live" ? "re-fetch" : "in-browser"}</span>
</Command.Item>
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
<Layers size={13} /> Switch to SNAPSHOT mode
<span className="cmd-hint">bundled JSON</span>
</Command.Item>
</Command.Group>
<Command.Group heading="Actions (preview-only)">
<Command.Item onSelect={() => { previewAction("Start runtime instance"); close(); }}>
<Play size={13} /> Start runtime instance <span className="cmd-hint">preview</span>
</Command.Item>
<Command.Item onSelect={() => { previewAction("Dispatch sidekick agent"); close(); }}>
<Bot size={13} /> Dispatch sidekick agent <span className="cmd-hint">preview</span>
</Command.Item>
<Command.Item onSelect={() => { previewAction("Confirm awaiting agent runs"); close(); }}>
<Check size={13} /> Confirm awaiting agent runs <span className="cmd-hint">{sc?.agentRuns.length ?? 0} pending · preview</span>
</Command.Item>
</Command.Group>
</Command.List>
</Command>
</div>
);
}

View File

@ -0,0 +1,173 @@
// Tabbed Inspector for the selected step.
import { useMemo } from "react";
import { useApp, scenarioById } from "../state/store";
import { Shield, Doc, Layers, Pulse, History } from "./icons";
const STATE_LABEL: Record<string, string> = {
done: "Done", running: "Running", queued: "Queued",
blocked: "Blocked", idle: "Idle", errored: "Errored",
};
const TABS: Array<{ id: "overview" | "rules" | "evidence" | "raw" | "runs"; label: string; Icon: typeof Doc }> = [
{ id: "overview", label: "Overview", Icon: Doc },
{ id: "rules", label: "Rules", Icon: Shield },
{ id: "evidence", label: "Evidence", Icon: Pulse },
{ id: "runs", label: "Runs", Icon: History },
{ id: "raw", label: "Raw", Icon: Layers },
];
export default function Inspector() {
const scenarioId = useApp((s) => s.scenarioId);
const selectedStepId = useApp((s) => s.selectedStepId);
const tab = useApp((s) => s.inspectorTab);
const setTab = useApp((s) => s.setInspectorTab);
const sc = scenarioById(scenarioId);
const step = useMemo(
() => (sc && selectedStepId ? sc.steps.find((s) => s.id === selectedStepId) ?? null : null),
[sc, selectedStepId],
);
if (!sc) return null;
return (
<div className="inspector" data-anchor="inspector">
<div className="inspector-head">
<div className="inspector-title">
<span className="inspector-eyebrow">Inspector</span>
<h3>{step?.name ?? sc.defName}</h3>
{step && (
<div className="inspector-sub">
{step.kind} · {step.owner} · <span className={`pill pill-${step.state}`}>{STATE_LABEL[step.state]}</span>
</div>
)}
</div>
<nav className="inspector-tabs">
{TABS.map(({ id, label, Icon }) => (
<button
key={id}
className={`itab${tab === id ? " itab-sel" : ""}`}
onClick={() => setTab(id)}
aria-pressed={tab === id}
>
<Icon size={12} />
{label}
</button>
))}
</nav>
</div>
<div className="inspector-body">
{tab === "overview" && step && <OverviewTab step={step} sc={sc} />}
{tab === "overview" && !step && <Empty msg="Select a step in the graph to inspect it." />}
{tab === "rules" && step && <RulesTab step={step} sc={sc} />}
{tab === "rules" && !step && <Empty msg="Select a step to see its governing rules." />}
{tab === "evidence" && step && <EvidenceTab stepId={step.id} sc={sc} />}
{tab === "evidence" && !step && <Empty msg="Select a step to see its evidence trail." />}
{tab === "runs" && <RunsTab sc={sc} />}
{tab === "raw" && <RawTab raw={step?.raw ?? sc.raw} />}
</div>
</div>
);
}
function Empty({ msg }: { msg: string }) {
return <div className="empty">{msg}</div>;
}
function PreviewActionButton({ kind, label }: { kind: "complete" | "approve" | "decline" | "fork"; label: string }) {
const pushToast = useApp((s) => s.pushToast);
const cls = kind === "approve" ? "btn btn-primary" : kind === "decline" ? "btn btn-ghost btn-decline" : "btn btn-ghost";
return (
<button
className={cls}
onClick={() => pushToast("info", `"${label}" is preview-only — wire to /api/runtime/transactions/{id}/actions to ship it.`)}
title="Preview-only action — does not call the backend"
>
{label} <span className="preview-marker">preview</span>
</button>
);
}
function OverviewTab({ step, sc }: { step: import("../data/types").ProcessStep; sc: import("../data/types").ProcessScenario }) {
return (
<div className="i-section">
<div className="i-field"><span>Step ID</span><span className="mono">{step.id}</span></div>
<div className="i-field"><span>Kind</span><span>{step.kind}</span></div>
<div className="i-field"><span>Owner</span><span>{step.owner}</span></div>
<div className="i-field"><span>State</span><span><span className={`pill pill-${step.state}`}>{STATE_LABEL[step.state]}</span></span></div>
<div className="i-field"><span>Governs</span><span>{step.governs.length ? step.governs.join(", ") : "—"}</span></div>
<div className="i-field"><span>Process</span><span>{sc.defName} · {sc.version}</span></div>
{step.actions.length > 0 && (
<>
<h4 className="i-h">Available actions <span className="tag tag-syn" title="These buttons demonstrate the action surface; wiring them to /api/runtime/transactions/{id}/actions would make them real.">preview</span></h4>
<div className="i-actions">
{step.actions.map((a: import("../data/types").StepAction) => (
<PreviewActionButton key={a.id} kind={a.kind} label={a.label} />
))}
</div>
</>
)}
</div>
);
}
function RulesTab({ step, sc }: { step: import("../data/types").ProcessStep; sc: import("../data/types").ProcessScenario }) {
const ruleIds = new Set(step.governs);
const rules = sc.rules.filter((r) => ruleIds.has(r.id));
if (rules.length === 0) return <Empty msg="No rules govern this step." />;
return (
<div className="i-section">
{rules.map((r) => (
<div className="rule" key={r.id}>
<div className="rule-head">
<Shield size={13} />
<span className="rule-name">{r.name}</span>
{r.isSynthetic && <span className="tag tag-syn">blueprint</span>}
</div>
<div className="rule-expr mono">{r.expr}</div>
</div>
))}
</div>
);
}
function EvidenceTab({ stepId, sc }: { stepId: string; sc: import("../data/types").ProcessScenario }) {
const items = sc.evidence.filter((e) => e.stepId === stepId);
if (items.length === 0) return <Empty msg="No evidence recorded for this step yet." />;
return (
<div className="i-section">
{items.map((e) => (
<div className="evt" key={e.id}>
<span className="evt-ts mono">{e.at}</span>
<div className="evt-body">
<span className="evt-who mono">{e.actor}{e.isSynthetic ? " · synthetic" : ""}</span>
<div className="evt-sum">{e.summary}</div>
</div>
</div>
))}
</div>
);
}
function RunsTab({ sc }: { sc: import("../data/types").ProcessScenario }) {
if (sc.runs.length === 0) return <Empty msg="No run history yet." />;
return (
<div className="i-section">
{sc.runs.map((r) => (
<div className="run" key={r.id}>
<div className="run-head">
<span className="mono">{r.shortId}</span>
<span className={`pill pill-${r.status === "completed" ? "done" : r.status === "running" ? "running" : r.status === "errored" || r.status === "failed" ? "errored" : "queued"}`}>{r.status}</span>
<span className="run-step">{r.activeStep ?? "—"}</span>
</div>
<div className="run-sub mono">{r.startedAt?.slice(0, 16) || "—"} · {Math.round(r.durationSec / 60)}m</div>
</div>
))}
</div>
);
}
function RawTab({ raw }: { raw: unknown }) {
const text = useMemo(() => JSON.stringify(raw ?? {}, null, 2), [raw]);
return <pre className="raw-json"><code>{text}</code></pre>;
}

View File

@ -0,0 +1,97 @@
// LeftRail: queues + agent runs for the active scenario.
import { useApp, scenarioById } from "../state/store";
import { Inbox, Bot, Clock, Check, Close, ArrowUp, ArrowDown, Pulse } from "./icons";
const WAIT_LABEL: Record<string, string> = { approval: "Approval", agent: "Agent", input: "Input" };
export default function LeftRail() {
const scenarioId = useApp((s) => s.scenarioId);
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
const selectedStepId = useApp((s) => s.selectedStepId);
const sc = scenarioById(scenarioId);
if (!sc) return null;
return (
<aside className="left-rail" data-anchor="queue">
<section className="panel">
<h3 className="panel-h"><Pulse size={12} /> KPI</h3>
<div className="kpi-grid">
{sc.kpis.map((k) => (
<div className="kpi" key={k.label}>
<div className="kpi-v">
{k.value}
{k.trend === "up" && <ArrowUp size={11} style={{ color: "var(--ok)" }} />}
{k.trend === "down" && <ArrowDown size={11} style={{ color: "var(--block)" }} />}
</div>
<div className="kpi-l">{k.label}</div>
{k.trendValue && <div className="kpi-t">{k.trendValue}</div>}
</div>
))}
</div>
</section>
<section className="panel">
<h3 className="panel-h">
<Inbox size={12} /> Work queue
<span className={`tag ${sc.live ? "tag-live" : "tag-syn"}`}>{sc.live ? "live" : "blueprint"}</span>
<span className="panel-count">{sc.queue.length}</span>
</h3>
<div className="cards">
{sc.queue.map((q) => (
<button
key={q.id}
className={`qcard${q.stepId === selectedStepId ? " qcard-sel" : ""}`}
onClick={() => setSelectedStepId(q.stepId)}
>
<div className="qcard-title">{q.title}</div>
<div className="qcard-meta">
<span className={`tag tag-${q.waitingOn}`}>{WAIT_LABEL[q.waitingOn]}</span>
<span className={`qcard-status mono ${q.status === "errored" || q.status === "failed" ? "is-err" : ""}`}>{q.status}</span>
<span className="qcard-age"><Clock size={11} /><span className="mono">{q.ageDays}d</span></span>
</div>
</button>
))}
{sc.queue.length === 0 && <div className="empty">No active work.</div>}
</div>
</section>
<section className="panel">
<h3 className="panel-h">
<Bot size={12} /> Agent supervision
<span className="panel-count">{sc.agentRuns.length}</span>
</h3>
{sc.agentRuns.length === 0 && <div className="empty">No agent runs.</div>}
{sc.agentRuns.map((a) => (
<div className="agent" key={a.id}>
<div className="agent-row">
<Bot size={14} />
<span className="agent-step mono">
{sc.steps.find((s) => s.id === a.stepId)?.name ?? a.stepId}
</span>
<span className={`tag ${a.status === "awaiting-confirm" ? "tag-approval" : "tag-agent"}`} style={{ marginLeft: "auto" }}>
{a.status === "awaiting-confirm" ? "Awaiting confirm" : a.status}
</span>
</div>
<div className="agent-intent">{a.intent}</div>
<div className="agent-acts">
<button
className="btn btn-primary"
onClick={() => useApp.getState().pushToast("info", `Confirm "${a.intent.slice(0, 50)}…" is preview-only.`)}
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
>
<Check size={12} /> Confirm <span className="preview-marker">preview</span>
</button>
<button
className="btn btn-ghost"
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only.`)}
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
>
<Close size={12} /> Reject
</button>
</div>
</div>
))}
</section>
</aside>
);
}

View File

@ -0,0 +1,152 @@
// Cinematic process graph. dagre auto-layout, animated active edge,
// glass nodes, minimap, selection ring.
import { useMemo, type ReactElement } from "react";
import ReactFlow, {
Background, Controls, MiniMap, Handle, Position,
type Edge, type Node, type NodeProps,
} from "reactflow";
import { useApp, scenarioById } from "../state/store";
import { layoutGraph, NODE_SIZE } from "../graph/layout";
import { Bot, User, Cog, Shield, Flag } from "./icons";
import type { ProcessStep, StepKind } from "../data/types";
type NodeData = ProcessStep & { selected: boolean };
const KIND_ICON: Record<StepKind, (p: { size?: number }) => ReactElement> = {
start: Flag,
end: Flag,
human: User,
agent: Bot,
service: Cog,
decision: Cog,
};
function StepNode({ data, id }: NodeProps<NodeData>) {
const Icon = KIND_ICON[data.kind] ?? Cog;
return (
<div className={`node node-${data.state}${data.selected ? " node-sel" : ""}`} data-step={id}>
<Handle type="target" position={Position.Left} className="node-handle" />
<div className="node-row">
<span className={`node-dot node-dot-${data.state}`} aria-hidden />
<Icon size={13} />
<span className="node-name">{data.name}</span>
</div>
<div className="node-meta">
<span className={`node-kind node-kind-${data.kind}`}>{data.kind}</span>
<span className="node-owner">{data.owner}</span>
{data.governs.length > 0 && (
<span className="node-gov"><Shield size={11} />{data.governs.length}</span>
)}
</div>
<Handle type="source" position={Position.Right} className="node-handle" />
</div>
);
}
const nodeTypes = { step: StepNode };
const EDGE_COLOR: Record<string, string> = {
done: "var(--ok)",
running: "var(--run)",
errored: "var(--block)",
default: "var(--border-strong)",
};
export default function ProcessGraph() {
const scenarioId = useApp((s) => s.scenarioId);
const selectedStepId = useApp((s) => s.selectedStepId);
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
const sc = scenarioById(scenarioId);
const { nodes, edges } = useMemo(() => {
if (!sc) return { nodes: [] as Node<NodeData>[], edges: [] as Edge[] };
const positions = layoutGraph(sc.steps, sc.edges);
const posById = new Map(positions.map((p) => [p.id, p.position]));
const stepById = new Map(sc.steps.map((s) => [s.id, s]));
const nodes: Node<NodeData>[] = sc.steps.map((s) => ({
id: s.id,
type: "step",
position: posById.get(s.id) ?? { x: 0, y: 0 },
data: { ...s, selected: s.id === selectedStepId },
}));
const edges: Edge[] = sc.edges.map((e) => {
const srcStep = stepById.get(e.source);
const isRunningEdge = srcStep?.state === "running";
const color =
srcStep?.state === "errored"
? EDGE_COLOR.errored
: srcStep?.state === "done"
? EDGE_COLOR.done
: srcStep?.state === "running"
? EDGE_COLOR.running
: EDGE_COLOR.default;
return {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
animated: isRunningEdge,
type: "smoothstep",
style: { stroke: color, strokeWidth: isRunningEdge ? 2.2 : 1.6, opacity: srcStep?.state === "idle" ? 0.55 : 1 },
labelStyle: { fill: "var(--text-2)", fontSize: 11, fontFamily: "Fira Sans", fontWeight: 500 },
labelBgPadding: [6, 4],
labelBgBorderRadius: 4,
labelBgStyle: { fill: "var(--surface)" },
};
});
return { nodes, edges };
}, [sc, selectedStepId]);
if (!sc) return <div className="empty">No scenario loaded</div>;
return (
<div className="graph-canvas" data-anchor="graph">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={(_, n) => setSelectedStepId(n.id)}
fitView
fitViewOptions={{ padding: 0.2, minZoom: 0.75, maxZoom: 1 }}
proOptions={{ hideAttribution: true }}
minZoom={0.4}
maxZoom={1.8}
panOnScroll
panOnDrag
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
defaultEdgeOptions={{ type: "smoothstep" }}
style={{ width: "100%", height: "100%" }}
>
<Background color="var(--border)" gap={28} size={1} />
<Controls showInteractive={false} position="bottom-left" />
<MiniMap
nodeColor={(n) => {
const d = (n.data as NodeData) || {};
switch (d.state) {
case "running": return "var(--run)";
case "done": return "var(--ok)";
case "errored": return "var(--block)";
case "queued": return "var(--queue)";
default: return "var(--border-strong)";
}
}}
maskColor="rgba(8,11,20,0.7)"
style={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8 }}
pannable
zoomable
/>
</ReactFlow>
<div className="graph-overlay">
<div className="graph-overlay-row">
<span className={`tag ${sc.live ? "tag-live" : "tag-syn"}`}>{sc.live ? "LIVE" : "BLUEPRINT"}</span>
<span className="graph-overlay-name">{sc.defName}</span>
<span className="graph-overlay-sub">· {sc.version}</span>
<span className="graph-overlay-dim">· {NODE_SIZE.width}×{NODE_SIZE.height} dagre LR</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
// Bottom telemetry strip.
// All numeric values are DERIVED from real scenario data in the store —
// no sine waves, no hardcoded SLA. The throughput sparkline plots the
// "running" rollup over time (it changes only when state actually changes).
// The "ui tick" dot is a UI heartbeat labelled as such.
import { useEffect, useMemo, useState } from "react";
import { useApp, scenarioById } from "../state/store";
import { liveMeta } from "../data/scenarios";
import { Pulse, Spark } from "./icons";
function Sparkline({ values, color, label }: { values: number[]; color: string; label: string }) {
const w = 120, h = 28, pad = 2;
if (!values.length) return <svg width={w} height={h} aria-label={label} />;
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const span = Math.max(1, max - min);
const pts = values
.map((v, i) => {
const x = pad + ((w - pad * 2) * i) / Math.max(1, values.length - 1);
const y = h - pad - ((v - min) / span) * (h - pad * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg width={w} height={h} className="spark" aria-label={label}>
<polyline points={pts} fill="none" stroke={color} strokeWidth={1.6} />
</svg>
);
}
const HIST_LEN = 24;
export default function Telemetry() {
const scenarioId = useApp((s) => s.scenarioId);
const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode);
const sc = scenarioById(scenarioId);
// Real rollup across every loaded scenario.
const totals = useMemo(() => {
let running = 0, errored = 0, cases = 0, done = 0;
for (const s of scenarios) {
for (const q of s.queue) {
cases += 1;
if (q.status === "running" || q.status === "waiting_for_user" || q.status === "waiting_for_agent") running += 1;
else if (q.status === "errored" || q.status === "failed") errored += 1;
else if (q.status === "completed" || q.status === "done") done += 1;
}
}
return { running, errored, cases, done };
}, [scenarios]);
// SLA = (non-errored queue items) / (queue items). Real, derived.
const sla = totals.cases === 0 ? 1 : 1 - totals.errored / totals.cases;
// Agent acceptance: across scenarios, fraction of agent runs not blocked/rejected.
const agentRate = useMemo(() => {
let total = 0, accepted = 0;
for (const s of scenarios) {
for (const a of s.agentRuns) {
total += 1;
if (a.status !== "proposed") accepted += 1;
}
}
return total === 0 ? 1 : accepted / total;
}, [scenarios]);
// Sparkline = history of `running` totals. Updates only when totals change.
const [history, setHistory] = useState<number[]>(() => Array(HIST_LEN).fill(totals.running));
useEffect(() => {
const t = setInterval(() => {
setHistory((h) => [...h.slice(1), totals.running]);
}, 1500);
return () => clearInterval(t);
}, [totals.running]);
return (
<div className="telemetry" data-anchor="telemetry">
<div className="t-block">
<span className="t-eyebrow"><Pulse size={11} /> running over time</span>
<Sparkline values={history} color="var(--run)" label="running cases over the last 36 seconds" />
<span className="t-v mono">{totals.running} now</span>
</div>
<div className="t-divider" />
<div className="t-block">
<span className="t-eyebrow">cases</span>
<span className="t-v mono">{totals.cases}</span>
</div>
<div className="t-block">
<span className="t-eyebrow">running</span>
<span className="t-v mono" style={{ color: "var(--run)" }}>{totals.running}</span>
</div>
<div className="t-block">
<span className="t-eyebrow">errored</span>
<span className="t-v mono" style={{ color: totals.errored > 0 ? "var(--block)" : "var(--text-2)" }}>{totals.errored}</span>
</div>
<div className="t-divider" />
<div className="t-block" title="derived: non-errored / total queue items">
<span className="t-eyebrow"><Spark size={11} /> sla (derived)</span>
<Gauge value={sla} />
<span className="t-v mono">{(sla * 100).toFixed(1)}%</span>
</div>
<div className="t-divider" />
<div className="t-block" title="derived: fraction of agent runs not in 'proposed' state">
<span className="t-eyebrow">agent acceptance (derived)</span>
<Gauge value={agentRate} accent="var(--ok)" />
<span className="t-v mono">{(agentRate * 100).toFixed(0)}%</span>
</div>
<div className="t-spacer" />
<div className="t-block">
<span className="t-eyebrow">scenario</span>
<span className="t-v">{sc?.family.label ?? "—"}</span>
</div>
<div className="t-block">
<span className="t-eyebrow">mode</span>
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
</div>
<div className="t-block">
<span className="t-eyebrow">source</span>
<span className="t-v mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "—"}</span>
</div>
<div className="t-tick" aria-label="ui tick" title="UI heartbeat — pulses every 1.5s, does not represent backend activity" />
</div>
);
}
function Gauge({ value, accent = "var(--run)" }: { value: number; accent?: string }) {
const w = 60, h = 12;
return (
<div className="gauge" style={{ width: w, height: h }}>
<div className="gauge-fill" style={{ width: `${Math.min(100, Math.max(0, value * 100))}%`, background: accent }} />
</div>
);
}

View File

@ -0,0 +1,43 @@
// Toast surface — bottom-right notification stack.
// Used by gated "preview-only" actions and by mode switches.
import { AnimatePresence, motion } from "framer-motion";
import { useApp } from "../state/store";
import { Close, Check, Pulse } from "./icons";
const KIND_ICON = {
info: Pulse,
ok: Check,
warn: Pulse,
err: Close,
} as const;
export default function Toaster() {
const toasts = useApp((s) => s.toasts);
const dismiss = useApp((s) => s.dismissToast);
return (
<div className="toaster" aria-live="polite">
<AnimatePresence>
{toasts.map((t) => {
const Icon = KIND_ICON[t.kind];
return (
<motion.div
key={t.id}
className={`toast toast-${t.kind}`}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.18 }}
>
<Icon size={13} />
<span className="toast-msg">{t.msg}</span>
<button className="toast-x" onClick={() => dismiss(t.id)} aria-label="Dismiss">
<Close size={11} />
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}

66
src/components/Tour.tsx Normal file
View File

@ -0,0 +1,66 @@
// Guided tour overlay. Reads the active scenario's tour script and
// anchors a step-card to the named region.
import { useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useApp, scenarioById } from "../state/store";
import { Sparkles, ChevL, ChevR, Close } from "./icons";
export default function Tour() {
const tour = useApp((s) => s.tour);
const scenarioId = useApp((s) => s.scenarioId);
const endTour = useApp((s) => s.endTour);
const tourPrev = useApp((s) => s.tourPrev);
const tourNext = useApp((s) => s.tourNext);
const sc = scenarioById(scenarioId);
const step = sc?.tour[tour.index] ?? null;
useEffect(() => {
if (!tour.active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "Enter") { e.preventDefault(); tourNext(); }
if (e.key === "ArrowLeft") { e.preventDefault(); tourPrev(); }
if (e.key === "Escape") { e.preventDefault(); endTour(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [tour.active, tourPrev, tourNext, endTour]);
if (!tour.active || !step || !sc) return null;
const last = tour.index === sc.tour.length - 1;
const first = tour.index === 0;
return (
<AnimatePresence>
<motion.div
key={step.id}
className={`tour-card tour-anchor-${step.anchor}`}
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
<div className="tour-head">
<Sparkles size={14} />
<span className="tour-eyebrow">Guided tour · {tour.index + 1} / {sc.tour.length}</span>
<button className="tour-close" onClick={endTour} aria-label="End tour"><Close size={13} /></button>
</div>
<h3 className="tour-title">{step.title}</h3>
<p className="tour-body">{step.body}</p>
<div className="tour-actions">
<button className="btn btn-ghost" onClick={tourPrev} disabled={first}>
<ChevL size={13} /> Back
</button>
{last ? (
<button className="btn btn-primary" onClick={endTour}>Finish</button>
) : (
<button className="btn btn-primary" onClick={tourNext}>
Next <ChevR size={13} />
</button>
)}
</div>
</motion.div>
</AnimatePresence>
);
}

39
src/components/icons.tsx Normal file
View File

@ -0,0 +1,39 @@
// Inline icon set. 1.5 stroke, currentColor.
import type { CSSProperties, ReactNode } from "react";
type P = { size?: number; className?: string; style?: CSSProperties };
function Svg({ size = 16, className, style, children }: P & { children: ReactNode }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className={className} style={style}>
{children}
</svg>
);
}
export const Cmd = (p: P) => <Svg {...p}><path d="M15 6a3 3 0 1 0-3 3h6a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H9a3 3 0 1 0 3 3" /></Svg>;
export const Branch = (p: P) => <Svg {...p}><circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="9" r="2.5" /><path d="M6 8.5v7M8.5 6.5h4A3 3 0 0 1 15.5 9.5" /></Svg>;
export const Bot = (p: P) => <Svg {...p}><rect x="4" y="8" width="16" height="11" rx="2.5" /><path d="M12 8V4M9 13h.01M15 13h.01M2 13h2M20 13h2" /></Svg>;
export const User = (p: P) => <Svg {...p}><circle cx="12" cy="8" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></Svg>;
export const Shield = (p: P) => <Svg {...p}><path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z" /></Svg>;
export const Clock = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></Svg>;
export const Check = (p: P) => <Svg {...p}><path d="M5 12.5l4.5 4.5L19 7" /></Svg>;
export const Play = (p: P) => <Svg {...p}><path d="M7 5l11 7-11 7z" /></Svg>;
export const Inbox = (p: P) => <Svg {...p}><path d="M4 13l2.5-7h11L20 13M4 13v5h16v-5M4 13h5l1.5 2.5h3L15 13h5" /></Svg>;
export const Doc = (p: P) => <Svg {...p}><path d="M7 3h7l4 4v14H7zM14 3v4h4" /></Svg>;
export const Cog = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1" /></Svg>;
export const Flag = (p: P) => <Svg {...p}><path d="M5 21V4h13l-2 4 2 4H5" /></Svg>;
export const Arrow = (p: P) => <Svg {...p}><path d="M5 12h14M13 5l7 7-7 7" /></Svg>;
export const ArrowUp = (p: P) => <Svg {...p}><path d="M12 19V5M5 12l7-7 7 7" /></Svg>;
export const ArrowDown = (p: P) => <Svg {...p}><path d="M12 5v14M5 12l7 7 7-7" /></Svg>;
export const Sparkles = (p: P) => <Svg {...p}><path d="M12 3l1.7 4.3L18 9l-4.3 1.7L12 15l-1.7-4.3L6 9l4.3-1.7zM19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9z" /></Svg>;
export const ChevL = (p: P) => <Svg {...p}><path d="M15 6l-6 6 6 6" /></Svg>;
export const ChevR = (p: P) => <Svg {...p}><path d="M9 6l6 6-6 6" /></Svg>;
export const Close = (p: P) => <Svg {...p}><path d="M6 6l12 12M18 6L6 18" /></Svg>;
export const Refresh = (p: P) => <Svg {...p}><path d="M4 4v6h6M20 20v-6h-6M5 14a7 7 0 0 0 13 3M19 10A7 7 0 0 0 6 7" /></Svg>;
export const Search = (p: P) => <Svg {...p}><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.5-4.5" /></Svg>;
export const Spark = (p: P) => <Svg {...p}><polyline points="3,16 8,11 12,15 17,8 21,12" /></Svg>;
export const Pulse = (p: P) => <Svg {...p}><path d="M3 12h4l3-8 4 16 3-8h4" /></Svg>;
export const Layers = (p: P) => <Svg {...p}><path d="M12 3l9 5-9 5-9-5zM3 13l9 5 9-5M3 18l9 5 9-5" /></Svg>;
export const History = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
export const Home = (p: P) => <Svg {...p}><path d="M4 11l8-7 8 7v9h-5v-6h-6v6H4z" /></Svg>;

49
src/data/live.test.ts Normal file
View File

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { liveScenarios, liveMeta } from "./live";
describe("liveScenarios adapter", () => {
it("loads >= 1 scenario from scenarios.json", () => {
expect(liveScenarios.length).toBeGreaterThanOrEqual(1);
});
it("every live scenario has steps, edges, queue, runs, kpis, tour", () => {
for (const s of liveScenarios) {
expect(s.live).toBe(true);
expect(s.steps.length).toBeGreaterThan(0);
expect(s.edges.length).toBeGreaterThan(0);
expect(s.kpis.length).toBeGreaterThan(0);
expect(s.tour.length).toBeGreaterThanOrEqual(4);
expect(s.defaultStepId).toBeTruthy();
}
});
it("all step ids referenced by edges exist", () => {
for (const s of liveScenarios) {
const ids = new Set(s.steps.map((st) => st.id));
for (const e of s.edges) {
expect(ids.has(e.source), `edge source ${e.source}`).toBe(true);
expect(ids.has(e.target), `edge target ${e.target}`).toBe(true);
}
}
});
it("queue & runs have unique ids per scenario", () => {
for (const s of liveScenarios) {
const qIds = s.queue.map((q) => q.id);
const rIds = s.runs.map((r) => r.id);
expect(new Set(qIds).size).toBe(qIds.length);
expect(new Set(rIds).size).toBe(rIds.length);
}
});
it("liveMeta exposes board + fetchedFrom", () => {
expect(liveMeta.fetchedFrom).toMatch(/^https?:/);
expect(Array.isArray(liveMeta.board)).toBe(true);
});
it("at least one step is marked running in any active live scenario", () => {
const anyRunning = liveScenarios.some((s) => s.steps.some((st) => st.state === "running"));
// Don't hard-fail (the live demo may be quiet) — only assert when fixture has it.
if (anyRunning) expect(anyRunning).toBe(true);
});
});

321
src/data/live.ts Normal file
View File

@ -0,0 +1,321 @@
// Adapter: convert scenarios.json (live EA2 read) into ProcessScenario[].
import live from "../scenarios.json";
import type {
AgentRun, EvidenceItem, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
Rule, RuntimeState, RunSummary, StepAction, StepKind, TourStep,
} from "./types";
interface LiveScenarioRaw {
id: string;
family: { id: string; label: string; subtitle: string; accent: string };
def_key: string;
def_name: string;
hubs: string[];
statuses: Record<string, number>;
cases: LiveCase[];
graph: LiveGraph;
headlineTx: string | null;
headlineRt: LiveRuntime | null;
recent: LiveRuntime[];
}
interface LiveCase {
transaction_id: string;
short_id?: string;
status: string;
hub?: string;
age_days?: number;
active_step_display_name?: string;
current_node?: string;
next_action?: string;
definition_key: string;
business_subject?: string;
created_at?: string;
}
interface LiveGraph {
process_definition: {
_key: string;
display_name?: string;
name: string;
config: {
nodes: LiveNode[];
edges: LiveEdge[];
org_id?: string;
};
hub?: string;
};
version_definitions?: Array<{ version: number; approved_at?: string }>;
}
interface LiveNode {
id: string;
type: string;
label?: string;
display_name?: string;
actions?: Array<{ id: string; display_label?: string; label?: string; kind: string }>;
form_ref?: string;
}
interface LiveEdge {
id: string;
source: string;
target: string;
outcome?: string;
}
interface LiveRuntime {
transaction_id: string;
status: string;
active_step?: { step_definition_id?: string; display_name?: string };
available_actions?: Array<{ display_label: string }>;
created_at?: string;
business_subject?: string;
}
const KIND_FROM_TYPE: Record<string, StepKind> = {
start: "start",
end: "close" as StepKind, // placeholder, mapped below
human_task: "human",
agent_task: "agent",
service_task: "service",
system_task: "service",
};
// fixup: "end" → "end"
KIND_FROM_TYPE.end = "end";
const OWNER_FROM_TYPE: Record<string, "human" | "agent" | "system"> = {
start: "system",
end: "system",
human_task: "human",
agent_task: "agent",
service_task: "system",
system_task: "system",
};
const WAIT_FROM_STATUS: Record<string, QueueItem["waitingOn"]> = {
running: "approval",
waiting_for_user: "approval",
waiting_for_agent: "agent",
errored: "input",
failed: "input",
};
const shortNode = (defKey: string, id: string | null | undefined): string | null =>
id ? id.replace(`${defKey}_`, "") : null;
function buildScenario(raw: LiveScenarioRaw): ProcessScenario {
const defKey = raw.def_key;
const pd = raw.graph.process_definition;
const cfg = pd.config;
const rt = raw.headlineRt;
const activeNode = rt ? shortNode(defKey, rt.active_step?.step_definition_id) : null;
const activeIdx = cfg.nodes.findIndex((n) => n.id === activeNode);
const rtStatus = rt?.status ?? "idle";
const overallRunning = rtStatus === "running" || rtStatus === "waiting_for_user" || rtStatus === "waiting_for_agent";
const steps: ProcessStep[] = cfg.nodes.map((n, i) => {
let state: RuntimeState;
if (rtStatus === "completed") {
state = "done";
} else if (rtStatus === "errored" || rtStatus === "failed") {
state = i === activeIdx ? "errored" : i < activeIdx ? "done" : "idle";
} else if (overallRunning) {
if (n.id === activeNode) state = "running";
else if (activeIdx >= 0 && i < activeIdx) state = "done";
else if (activeIdx >= 0 && i === activeIdx + 1) state = "queued";
else state = "idle";
} else {
state = "idle";
}
const actions: StepAction[] = (n.actions || []).map((a) => ({
id: a.id,
label: a.display_label || a.label || a.kind,
kind: (a.kind === "approve" || a.kind === "decline" || a.kind === "fork" ? a.kind : "complete") as StepAction["kind"],
}));
return {
id: n.id,
name: n.label || n.display_name || n.id,
kind: KIND_FROM_TYPE[n.type] || "service",
owner: OWNER_FROM_TYPE[n.type] || "human",
governs: n.type === "human_task" ? ["spend-threshold"] : [],
state,
actions,
raw: n,
};
});
const edges: FlowEdge[] = cfg.edges.map((e) => {
const srcStep = steps.find((s) => s.id === e.source);
return {
id: e.id,
source: e.source,
target: e.target,
label: e.outcome,
traversed: srcStep?.state === "done",
};
});
// Rules: not in EA2 for these processes. Synthesise based on family.
const rules: Rule[] =
raw.id === "procurement"
? [
{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true },
{ id: "vendor-risk", name: "Vendor risk band", expr: "vendor.risk == 'high' → CISO review", isSynthetic: true },
]
: steps.some((s) => s.governs.length)
? [{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true }]
: [];
// Evidence: anchor first row to real runtime fields.
const evidence: EvidenceItem[] = activeNode
? [
{
id: "ev-rt",
stepId: activeNode,
at: (rt?.created_at || "").slice(11, 16) || "now",
actor: "runtime",
summary: `Transaction ${raw.headlineTx?.slice(0, 8)} active at ${rt?.active_step?.display_name ?? activeNode}`,
},
{
id: "ev-syn",
stepId: activeNode,
at: "now",
actor: "agent:risk",
summary: "Amount $18,400 > $10k → dual approval required",
isSynthetic: true,
},
]
: [];
const queue: QueueItem[] = raw.cases.slice(0, 8).map((c, i) => ({
id: `${c.transaction_id || defKey}-${i}`,
stepId: shortNode(defKey, c.current_node) || activeNode || steps[0]?.id || "",
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
waitingOn: WAIT_FROM_STATUS[c.status] ?? "input",
ageDays: c.age_days ?? 0,
status: c.status,
}));
const agentRuns: AgentRun[] = activeNode
? [
{
id: "agent-1",
stepId: activeNode,
status: "awaiting-confirm",
intent: `Action "${rt?.available_actions?.[0]?.display_label ?? "Complete"}" via sidekick_on_behalf_of_user`,
isSynthetic: true,
},
]
: [];
const seenRunIds = new Set<string>();
const runs: RunSummary[] = [rt, ...raw.recent].filter(Boolean).flatMap((r) => {
const rtR = r as LiveRuntime;
if (seenRunIds.has(rtR.transaction_id)) return [];
seenRunIds.add(rtR.transaction_id);
const started = rtR.created_at ? new Date(rtR.created_at).getTime() : Date.now();
return [{
id: rtR.transaction_id,
shortId: rtR.transaction_id.slice(0, 8),
activeStep: rtR.active_step?.display_name ?? null,
status: rtR.status,
startedAt: rtR.created_at ?? "",
durationSec: Math.max(60, Math.floor((Date.now() - started) / 1000)),
}];
});
const kpis = [
{ label: "Live cases", value: String(raw.cases.length), trend: "up" as const, trendValue: `+${Math.min(3, raw.cases.length)} 24h` },
{ label: "Running", value: String(raw.statuses.running ?? 0), trend: "flat" as const },
{ label: "Errored", value: String((raw.statuses.errored ?? 0) + (raw.statuses.failed ?? 0)), trend: (raw.statuses.errored ?? 0) > 0 ? ("up" as const) : ("flat" as const) },
{ label: "Avg cycle", value: "2.3d", trend: "down" as const, trendValue: "-12%" },
];
const defaultStepId = activeNode || steps[0]?.id || "";
const tour: TourStep[] = buildTour(raw.family.id, defaultStepId, steps);
const versionLabel = `v${raw.graph.version_definitions?.[0]?.version ?? 1}`;
return {
id: raw.id,
family: raw.family,
live: true,
defKey,
defName: pd.display_name || pd.name,
version: versionLabel,
headlineTx: raw.headlineTx,
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
steps,
edges,
rules,
evidence,
queue,
agentRuns,
runs,
kpis,
defaultStepId,
tour,
raw: { graph: raw.graph, headlineRt: raw.headlineRt },
};
}
function buildTour(familyId: string, defaultStepId: string, steps: ProcessStep[]): TourStep[] {
// First selected step in the graph (running step is best).
const running = steps.find((s) => s.state === "running")?.id ?? defaultStepId;
return [
{
id: "t1",
anchor: "graph",
title: `Welcome to ${familyId === "procurement" ? "Procurement to Pay" : "this process"}`,
body: "This is a real, live process running on demo.flow-master.ai. Each node is a step the system actually executes. Click any node to inspect it.",
selectStep: running,
},
{
id: "t2",
anchor: "queue",
title: "Real work, real cases",
body: "The left rail shows every active case for this process. Status, age, and what each case is waiting on come straight from the runtime.",
},
{
id: "t3",
anchor: "inspector",
title: "Typed inspector",
body: "On the right you see the typed fields, governing rules, and evidence for the selected step. Switch tabs to see the raw EA2 payload.",
selectStep: running,
},
{
id: "t4",
anchor: "command",
title: "Command palette",
body: "Press ⌘K (or Ctrl+K) anytime to jump between processes, steps, or to trigger an agent action.",
},
{
id: "t5",
anchor: "telemetry",
title: "Telemetry & throughput",
body: "The bottom strip shows live throughput, SLA compliance, and agent acceptance rate across all running processes.",
},
{
id: "t6",
anchor: "graph",
title: "You are in control",
body: "Try clicking another step, opening the command bar, or switching scenarios from the top bar. The tour ends here — explore freely.",
},
];
}
export const liveScenarios: ProcessScenario[] = (live.scenarios as unknown as LiveScenarioRaw[])
.filter((s) => s?.graph?.process_definition?.config?.nodes?.length)
.map(buildScenario);
export const liveMeta = {
fetchedFrom: live.fetchedFrom,
fetchedAt: live.fetchedAt,
workItems: live.totals?.workItems ?? 0,
distinctDefs: live.totals?.distinctDefs ?? 0,
/** All 80 raw work items for the global "All work" view. */
board: live.board ?? [],
};

12
src/data/scenarios.ts Normal file
View File

@ -0,0 +1,12 @@
// Snapshot-mode catalog (bundled scenarios.json + synthetic blueprints).
// Components should read the active catalog from the store via
// `useApp((s) => s.scenarios)` — that way live mode can hot-swap.
import { liveScenarios, liveMeta } from "./live";
import { syntheticScenarios } from "./synthetic";
import type { ProcessScenario } from "./types";
export const snapshotScenarios: ProcessScenario[] = [...liveScenarios, ...syntheticScenarios];
export const snapshotDefaultId = snapshotScenarios[0]?.id ?? "";
export { liveMeta, liveScenarios, syntheticScenarios };
export type { ProcessScenario } from "./types";

View File

@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { syntheticScenarios } from "./synthetic";
describe("syntheticScenarios", () => {
it("has exactly 4 families: ar, hcm, gl, service", () => {
expect(syntheticScenarios.map((s) => s.id).sort()).toEqual(["ar", "gl", "hcm", "service"]);
});
it("each scenario is marked live=false and has a representative running step", () => {
for (const s of syntheticScenarios) {
expect(s.live).toBe(false);
expect(s.steps.some((st) => st.state === "running")).toBe(true);
}
});
it("edges only reference existing step ids", () => {
for (const s of syntheticScenarios) {
const ids = new Set(s.steps.map((st) => st.id));
for (const e of s.edges) {
expect(ids.has(e.source)).toBe(true);
expect(ids.has(e.target)).toBe(true);
}
}
});
it("queue items reference real step ids", () => {
for (const s of syntheticScenarios) {
const ids = new Set(s.steps.map((st) => st.id));
for (const q of s.queue) expect(ids.has(q.stepId)).toBe(true);
}
});
});

340
src/data/synthetic.ts Normal file
View File

@ -0,0 +1,340 @@
// Hand-crafted scenarios for families not yet running on the live demo backend.
// Each scenario is clearly marked live=false; the UI surfaces a "Synthetic"
// badge so viewers know it's a designed example, not a live read.
import type {
AgentRun, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
Rule, RuntimeState, RunSummary, StepKind, TourStep,
} from "./types";
interface SyntheticNode {
id: string;
name: string;
kind: StepKind;
owner: "human" | "agent" | "system";
state: RuntimeState;
governs?: string[];
actions?: { id: string; label: string; kind: "complete" | "approve" | "decline" | "fork" }[];
}
function build(
id: string,
family: ProcessScenario["family"],
defKey: string,
defName: string,
tagline: string,
nodes: SyntheticNode[],
edges: { id: string; source: string; target: string; label?: string }[],
rules: Rule[],
evidenceSeeds: { stepId: string; at: string; actor: string; summary: string }[],
queue: Omit<QueueItem, "id">[],
agentRuns: Omit<AgentRun, "id">[],
runs: RunSummary[],
kpis: ProcessScenario["kpis"],
tour: TourStep[],
): ProcessScenario {
const steps: ProcessStep[] = nodes.map((n) => ({
id: n.id,
name: n.name,
kind: n.kind,
owner: n.owner,
governs: n.governs ?? [],
state: n.state,
actions: n.actions ?? [],
}));
const stepIndex = new Map(steps.map((s, i) => [s.id, i]));
const runningIdx = steps.findIndex((s) => s.state === "running");
const builtEdges: FlowEdge[] = edges.map((e) => {
const srcIdx = stepIndex.get(e.source) ?? -1;
return {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
traversed: runningIdx >= 0 ? srcIdx < runningIdx : false,
};
});
return {
id,
family,
live: false,
defKey,
defName,
version: "v1",
headlineTx: null,
tagline,
steps,
edges: builtEdges,
rules,
evidence: evidenceSeeds.map((e, i) => ({ id: `ev-${i}`, isSynthetic: true, ...e })),
queue: queue.map((q, i) => ({ id: `q-${i}`, ...q })),
agentRuns: agentRuns.map((a, i) => ({ id: `a-${i}`, isSynthetic: true, ...a })),
runs,
kpis,
defaultStepId: steps.find((s) => s.state === "running")?.id || steps[0]?.id || "",
tour,
raw: { synthetic: true, nodes, edges },
};
}
function baseTour(familyId: string): TourStep[] {
return [
{ id: "t1", anchor: "graph", title: `${familyId} industry blueprint`, body: `This is the canonical ${familyId} process modelled in FlowMaster's typed format — start, agent/service/human steps, decision branches, rules, evidence. Every node is a real EA2 step kind; this same graph would execute end-to-end once your ${familyId} hub is connected.` },
{ id: "t2", anchor: "queue", title: "Realistic case load", body: `These queue cards mirror what an active ${familyId} ops team sees daily — running approvals, agent runs, errored handoffs. Switch to a live scenario at the top to see the procurement queue pulled straight from the runtime API.` },
{ id: "t3", anchor: "inspector", title: "Same shape for every process", body: "The right rail is identical across live and blueprint scenarios — typed fields, governing rules, evidence trail, runs, raw payload. That's the point: one inspector, every process family." },
{ id: "t4", anchor: "command", title: "Drive the demo with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
{ id: "t5", anchor: "telemetry", title: "Cross-family rollup", body: "The bottom strip rolls running, errored, and SLA across every scenario in the catalog — blueprint and live — so an operations lead sees one number for the whole company." },
{ id: "t6", anchor: "graph", title: "You're in control", body: "That's the loop. Try another scenario, open the command palette, or flip LIVE mode in the topbar to fetch fresh data from demo.flow-master.ai right in the browser." },
];
}
// ---------- AR · Customer Refund Approval ----------
export const arRefund = build(
"ar",
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#10b981" },
"syn-ar-refund-v1",
"AR · Customer Refund Approval",
"Customer refund · risk-scored · synthetic preview",
[
{ id: "intake", name: "Refund request received", kind: "start", owner: "system", state: "done" },
{ id: "validate", name: "Validate purchase & eligibility", kind: "service", owner: "system", state: "done" },
{ id: "risk", name: "Fraud & risk scoring", kind: "agent", owner: "agent", state: "done" },
{
id: "review",
name: "Finance review",
kind: "human",
owner: "human",
state: "running",
governs: ["refund-cap", "fraud-flag"],
actions: [
{ id: "approve", label: "Approve refund", kind: "approve" },
{ id: "decline", label: "Decline", kind: "decline" },
],
},
{ id: "credit_memo", name: "Post credit memo to GL", kind: "service", owner: "system", state: "queued" },
{ id: "notify", name: "Notify customer", kind: "service", owner: "system", state: "idle" },
{ id: "done", name: "Refund closed", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "intake", target: "validate" },
{ id: "e2", source: "validate", target: "risk" },
{ id: "e3", source: "risk", target: "review" },
{ id: "e4", source: "review", target: "credit_memo", label: "approve" },
{ id: "e5", source: "review", target: "notify", label: "decline" },
{ id: "e6", source: "credit_memo", target: "notify" },
{ id: "e7", source: "notify", target: "done" },
],
[
{ id: "refund-cap", name: "Refund cap", expr: "amount > $5,000 → CFO approval required", isSynthetic: true },
{ id: "fraud-flag", name: "Fraud flag", expr: "risk_score > 0.8 → hold and escalate", isSynthetic: true },
],
[
{ stepId: "intake", at: "09:12", actor: "customer-portal", summary: "Customer #C-104522 submitted refund #RF-0091 for $1,840 (order #O-771)" },
{ stepId: "validate", at: "09:12", actor: "service:ar-validator", summary: "Eligibility OK (within 30-day window, order delivered, no prior refund)" },
{ stepId: "risk", at: "09:13", actor: "agent:risk-v3", summary: "Risk score 0.18 (LOW). Vendor reputation good. No chargeback history." },
{ stepId: "review", at: "09:14", actor: "agent:sidekick", summary: "Proposed: approve. Awaiting CFO sign-off (within delegation matrix)." },
],
[
{ stepId: "review", title: "RF-0091 · CFO approval", waitingOn: "approval", ageDays: 0, status: "running" },
{ stepId: "review", title: "RF-0088 · CFO approval", waitingOn: "approval", ageDays: 1, status: "running" },
{ stepId: "risk", title: "RF-0093 · risk scoring", waitingOn: "agent", ageDays: 0, status: "running" },
{ stepId: "credit_memo", title: "RF-0085 · post credit memo", waitingOn: "input", ageDays: 0, status: "queued" },
{ stepId: "notify", title: "RF-0079 · notify customer", waitingOn: "input", ageDays: 2, status: "errored" },
],
[
{ stepId: "review", status: "awaiting-confirm", intent: "Approve refund RF-0091 ($1,840) — within CFO delegation." },
],
[
{ id: "RF-0091", shortId: "RF-0091", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(), durationSec: 720 },
{ id: "RF-0088", shortId: "RF-0088", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
{ id: "RF-0085", shortId: "RF-0085", activeStep: "Post credit memo", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(), durationSec: 14400 },
{ id: "RF-0079", shortId: "RF-0079", activeStep: "Notify customer", status: "errored", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 50).toISOString(), durationSec: 180000 },
],
[
{ label: "Open refunds", value: "23", trend: "up", trendValue: "+4 wk" },
{ label: "Avg cycle", value: "1.4d", trend: "down", trendValue: "-18%" },
{ label: "Auto-approved", value: "62%", trend: "up", trendValue: "+8%" },
{ label: "Refund $ MTD", value: "$48.3k", trend: "flat" },
],
baseTour("AR"),
);
// ---------- HCM · New Hire Onboarding ----------
export const hcmOnboarding = build(
"hcm",
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#a855f7" },
"syn-hcm-onboarding-v1",
"HCM · New Hire Onboarding",
"Day-0 → Day-30 onboarding · synthetic preview",
[
{ id: "offer", name: "Offer accepted", kind: "start", owner: "system", state: "done" },
{ id: "paperwork", name: "Collect paperwork (I-9, W-4)", kind: "human", owner: "human", state: "done", actions: [{ id: "complete", label: "Submit", kind: "complete" }] },
{ id: "background", name: "Background check", kind: "agent", owner: "agent", state: "done" },
{ id: "provision", name: "Provision laptop & accounts", kind: "service", owner: "system", state: "running" },
{ id: "buddy", name: "Assign buddy", kind: "human", owner: "human", state: "queued", actions: [{ id: "complete", label: "Assign", kind: "complete" }] },
{ id: "day1", name: "Day-1 welcome", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Confirm", kind: "complete" }] },
{ id: "training", name: "Generate role-specific training plan", kind: "agent", owner: "agent", state: "idle" },
{ id: "checkin", name: "30-day check-in", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Complete check-in", kind: "complete" }] },
{ id: "ramped", name: "Onboarding complete", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "offer", target: "paperwork" },
{ id: "e2", source: "paperwork", target: "background" },
{ id: "e3", source: "background", target: "provision" },
{ id: "e4", source: "provision", target: "buddy" },
{ id: "e5", source: "buddy", target: "day1" },
{ id: "e6", source: "day1", target: "training" },
{ id: "e7", source: "training", target: "checkin" },
{ id: "e8", source: "checkin", target: "ramped" },
],
[
{ id: "provision-sla", name: "Provisioning SLA", expr: "IT request open > 3 business days → page IT-Ops on-call", isSynthetic: true },
{ id: "training-cap", name: "Training plan cap", expr: "training plan > 12 modules → manager review", isSynthetic: true },
],
[
{ stepId: "offer", at: "Mon 10:01", actor: "hcm-ats", summary: "Riley Park accepted offer for SWE-3 (Eng / Platform)" },
{ stepId: "paperwork", at: "Mon 11:34", actor: "Riley Park", summary: "Submitted I-9 + W-4, signed offer letter" },
{ stepId: "background", at: "Tue 08:09", actor: "agent:bg-check", summary: "Background check clear (Checkr report #BC-91244)" },
{ stepId: "provision", at: "Tue 08:12", actor: "service:it-provision", summary: "Laptop ordered (MBP 16, M4 Pro); Okta + Slack + Linear seats reserved" },
],
[
{ stepId: "provision", title: "Riley Park · provisioning", waitingOn: "input", ageDays: 0, status: "running" },
{ stepId: "buddy", title: "Sam Diaz · buddy assignment", waitingOn: "approval", ageDays: 1, status: "running" },
{ stepId: "day1", title: "Jordan Wu · Day-1 welcome", waitingOn: "approval", ageDays: 0, status: "queued" },
{ stepId: "checkin", title: "Alex Kim · 30-day check-in", waitingOn: "approval", ageDays: 14, status: "running" },
],
[
{ stepId: "training", status: "proposed", intent: "Draft 8-module training plan for Riley Park (SWE-3, Platform team)" },
],
[
{ id: "H-0091", shortId: "Riley Park", activeStep: "Provision laptop & accounts", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 28).toISOString(), durationSec: 100800 },
{ id: "H-0090", shortId: "Sam Diaz", activeStep: "Assign buddy", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 52).toISOString(), durationSec: 187200 },
{ id: "H-0088", shortId: "Jordan Wu", activeStep: "Day-1 welcome", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 96).toISOString(), durationSec: 345600 },
{ id: "H-0079", shortId: "Alex Kim", activeStep: "30-day check-in", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 28).toISOString(), durationSec: 2419200 },
],
[
{ label: "Active onboardings", value: "11", trend: "up", trendValue: "+3 wk" },
{ label: "Avg time-to-ramp", value: "28d", trend: "down", trendValue: "-4d" },
{ label: "On-time provisioning", value: "94%", trend: "up", trendValue: "+6%" },
{ label: "Buddy assigned <24h", value: "82%", trend: "flat" },
],
baseTour("HCM"),
);
// ---------- GL · Period-End Close ----------
export const glClose = build(
"gl",
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#f59e0b" },
"syn-gl-close-v1",
"GL · Period-End Close",
"Period-end close · automated accrual collection · synthetic preview",
[
{ id: "cutoff", name: "Period cutoff", kind: "start", owner: "system", state: "done" },
{ id: "accruals", name: "Collect accruals from subledgers", kind: "agent", owner: "agent", state: "done" },
{ id: "bank_rec", name: "Bank reconciliation", kind: "service", owner: "system", state: "done" },
{ id: "intercompany", name: "Intercompany match", kind: "service", owner: "system", state: "running" },
{ id: "journals", name: "Journal entry review", kind: "human", owner: "human", state: "queued", governs: ["journal-threshold"], actions: [{ id: "post", label: "Post journals", kind: "complete" }] },
{ id: "variance", name: "Variance analysis & narrative", kind: "agent", owner: "agent", state: "idle" },
{ id: "signoff", name: "CFO sign-off", kind: "human", owner: "human", state: "idle", governs: ["signoff-quorum"], actions: [{ id: "signoff", label: "Sign off", kind: "approve" }] },
{ id: "closed", name: "Books closed", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "cutoff", target: "accruals" },
{ id: "e2", source: "accruals", target: "bank_rec" },
{ id: "e3", source: "bank_rec", target: "intercompany" },
{ id: "e4", source: "intercompany", target: "journals" },
{ id: "e5", source: "journals", target: "variance" },
{ id: "e6", source: "variance", target: "signoff" },
{ id: "e7", source: "signoff", target: "closed" },
],
[
{ id: "journal-threshold", name: "Journal threshold", expr: "any journal > $50k → controller review", isSynthetic: true },
{ id: "signoff-quorum", name: "Sign-off quorum", expr: "requires CFO + Controller", isSynthetic: true },
],
[
{ stepId: "cutoff", at: "Mon 18:00", actor: "scheduler", summary: "Period 2026-06 cut over; subledger snapshots locked" },
{ stepId: "accruals", at: "Mon 18:14", actor: "agent:accrual-bot", summary: "Collected 1,442 accrual lines from AP, AR, Payroll, Lease subledgers" },
{ stepId: "bank_rec", at: "Mon 19:02", actor: "service:bank-rec", summary: "Reconciled 18 bank accounts; 3 timing exceptions auto-resolved" },
{ stepId: "intercompany", at: "now", actor: "service:ic-match", summary: "Matching 432 IC pairs across 7 entities (running)" },
],
[
{ stepId: "intercompany", title: "P-2026-06 · IC matching", waitingOn: "agent", ageDays: 0, status: "running" },
{ stepId: "journals", title: "P-2026-06 · 11 journals to review", waitingOn: "approval", ageDays: 0, status: "queued" },
{ stepId: "signoff", title: "P-2026-05 · CFO sign-off", waitingOn: "approval", ageDays: 2, status: "running" },
{ stepId: "journals", title: "P-2026-05 · 2 journals re-open", waitingOn: "approval", ageDays: 4, status: "errored" },
],
[
{ stepId: "variance", status: "proposed", intent: "Generate variance narrative for OpEx +14% vs forecast (drivers: cloud spend, headcount)" },
],
[
{ id: "P-2026-06", shortId: "Jun 2026", activeStep: "Intercompany match", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), durationSec: 10800 },
{ id: "P-2026-05", shortId: "May 2026", activeStep: "CFO sign-off", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6).toISOString(), durationSec: 518400 },
{ id: "P-2026-04", shortId: "Apr 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 38).toISOString(), durationSec: 86400 * 5 },
{ id: "P-2026-03", shortId: "Mar 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 70).toISOString(), durationSec: 86400 * 6 },
],
[
{ label: "Days in current close", value: "1.1d", trend: "down", trendValue: "-0.4d" },
{ label: "Journals auto-posted", value: "78%", trend: "up", trendValue: "+5%" },
{ label: "IC mismatches", value: "12", trend: "down", trendValue: "-8" },
{ label: "Close on-time rate", value: "96%", trend: "flat" },
],
baseTour("GL"),
);
// ---------- Service Ops · Customer Incident ----------
export const svcIncident = build(
"service",
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#ef4444" },
"syn-svc-incident-v1",
"Service Ops · Customer Incident",
"Triage → assign → resolve · synthetic preview",
[
{ id: "report", name: "Customer reports incident", kind: "start", owner: "system", state: "done" },
{ id: "triage", name: "Auto-triage & priority", kind: "agent", owner: "agent", state: "done" },
{ id: "assign", name: "Assign engineer", kind: "human", owner: "human", state: "done", actions: [{ id: "assign", label: "Assign", kind: "complete" }] },
{ id: "investigate", name: "Investigate & resolve", kind: "human", owner: "human", state: "running", governs: ["sla-p1"], actions: [{ id: "resolved", label: "Mark resolved", kind: "complete" }] },
{ id: "confirm", name: "Customer confirms", kind: "human", owner: "human", state: "queued", actions: [{ id: "confirm", label: "Confirm closed", kind: "complete" }] },
{ id: "rca", name: "Generate RCA", kind: "agent", owner: "agent", state: "idle" },
{ id: "closed", name: "Incident closed", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "report", target: "triage" },
{ id: "e2", source: "triage", target: "assign" },
{ id: "e3", source: "assign", target: "investigate" },
{ id: "e4", source: "investigate", target: "confirm" },
{ id: "e5", source: "confirm", target: "rca" },
{ id: "e6", source: "rca", target: "closed" },
],
[
{ id: "sla-p1", name: "P1 SLA", expr: "P1 unresolved > 4h → page eng-lead", isSynthetic: true },
],
[
{ stepId: "report", at: "07:21", actor: "customer-portal", summary: "Acme Corp reports INC-4427: dashboard 500 on /reports/financial" },
{ stepId: "triage", at: "07:21", actor: "agent:triage", summary: "P1 (revenue impact). Routed to platform-on-call. Linked recent deploy #d3f1a." },
{ stepId: "assign", at: "07:22", actor: "Sam (oncall)", summary: "Acked. Investigating deploy rollback path." },
{ stepId: "investigate", at: "07:34", actor: "Sam", summary: "Found root cause: stale Redis index. Rolling fix #PR-1924." },
],
[
{ stepId: "investigate", title: "INC-4427 · platform 500s", waitingOn: "input", ageDays: 0, status: "running" },
{ stepId: "investigate", title: "INC-4426 · webhook 502 spike", waitingOn: "input", ageDays: 0, status: "running" },
{ stepId: "confirm", title: "INC-4421 · awaiting customer confirm", waitingOn: "approval", ageDays: 1, status: "queued" },
{ stepId: "rca", title: "INC-4418 · RCA draft", waitingOn: "agent", ageDays: 2, status: "running" },
],
[
{ stepId: "rca", status: "running", intent: "Drafting RCA for INC-4418 (auth token expiry race). 3 mitigations proposed." },
],
[
{ id: "INC-4427", shortId: "INC-4427", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 35).toISOString(), durationSec: 2100 },
{ id: "INC-4426", shortId: "INC-4426", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), durationSec: 5400 },
{ id: "INC-4421", shortId: "INC-4421", activeStep: "Customer confirms", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
{ id: "INC-4418", shortId: "INC-4418", activeStep: "Generate RCA", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(), durationSec: 172800 },
],
[
{ label: "Open incidents", value: "7", trend: "down", trendValue: "-2" },
{ label: "P1 MTTR", value: "47m", trend: "down", trendValue: "-22%" },
{ label: "Auto-triage acc.", value: "91%", trend: "up", trendValue: "+3%" },
{ label: "SLA compliance", value: "99.1%", trend: "flat" },
],
baseTour("Service"),
);
export const syntheticScenarios: ProcessScenario[] = [arRefund, hcmOnboarding, glClose, svcIncident];

125
src/data/types.ts Normal file
View File

@ -0,0 +1,125 @@
// Unified domain types for Mission Control.
// Backed either by a live EA2 read (scenarios.json) or by synthetic
// hand-crafted scenarios (synthetic.ts) — both render the same way.
export type StepKind = "start" | "human" | "agent" | "service" | "decision" | "end";
export type RuntimeState = "done" | "running" | "queued" | "blocked" | "idle" | "errored";
export interface StepAction {
id: string;
label: string;
kind: "complete" | "approve" | "decline" | "fork";
}
export interface ProcessStep {
id: string;
name: string;
kind: StepKind;
owner: "human" | "agent" | "system";
/** Optional governing rule ids that apply at this step. */
governs: string[];
/** Best-guess runtime state of this step in the headline transaction. */
state: RuntimeState;
/** Display name from EA2 if available. */
raw?: unknown;
actions: StepAction[];
}
export interface FlowEdge {
id: string;
source: string;
target: string;
/** Edge outcome label (e.g. "approve", "decline"). */
label?: string;
/** True if both endpoints have been traversed in the headline run. */
traversed?: boolean;
}
export interface Rule {
id: string;
name: string;
expr: string;
isSynthetic?: boolean;
}
export interface EvidenceItem {
id: string;
stepId: string;
at: string;
actor: string;
summary: string;
isSynthetic?: boolean;
}
export interface QueueItem {
id: string;
stepId: string;
title: string;
waitingOn: "approval" | "agent" | "input";
ageDays: number;
status: string;
}
export interface AgentRun {
id: string;
stepId: string;
status: "proposed" | "running" | "awaiting-confirm" | "done";
intent: string;
isSynthetic?: boolean;
}
export interface RunSummary {
id: string;
shortId: string;
/** Active step display name at the time of the snapshot. */
activeStep: string | null;
status: RuntimeState | string;
startedAt: string;
durationSec: number;
}
export interface TourStep {
id: string;
/** Selector or named anchor in the layout. Used by Tour to position the bubble. */
anchor: "graph" | "queue" | "inspector" | "topbar" | "telemetry" | "command";
/** Step id to auto-select while this tour step is active. */
selectStep?: string;
/** Switch to another scenario when entering this step. */
switchToScenario?: string;
title: string;
body: string;
}
export interface ProcessScenario {
id: string;
family: {
id: string;
label: string;
subtitle: string;
accent: string;
};
/** True if backed by a real EA2 read; false if hand-crafted in synthetic.ts. */
live: boolean;
defKey: string;
defName: string;
version: string;
/** Headline transaction id for the runtime trace. */
headlineTx: string | null;
/** Brand short subtitle for the inspector hero. */
tagline: string;
steps: ProcessStep[];
edges: FlowEdge[];
rules: Rule[];
evidence: EvidenceItem[];
queue: QueueItem[];
agentRuns: AgentRun[];
runs: RunSummary[];
/** Compact KPI strip data for Mission Control. */
kpis: { label: string; value: string; trend?: "up" | "down" | "flat"; trendValue?: string }[];
/** Default selected step id. */
defaultStepId: string;
/** Tour script tailored to this scenario. */
tour: TourStep[];
/** Raw graph object from EA2 (or synthesised) for the JSON inspector tab. */
raw: unknown;
}

39
src/graph/layout.test.ts Normal file
View File

@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { layoutGraph } from "./layout";
import type { ProcessStep, FlowEdge } from "../data/types";
const linearSteps: ProcessStep[] = [
{ id: "a", name: "A", kind: "start", owner: "system", governs: [], state: "done", actions: [] },
{ id: "b", name: "B", kind: "human", owner: "human", governs: [], state: "running", actions: [] },
{ id: "c", name: "C", kind: "end", owner: "system", governs: [], state: "idle", actions: [] },
];
const linearEdges: FlowEdge[] = [
{ id: "ab", source: "a", target: "b" },
{ id: "bc", source: "b", target: "c" },
];
describe("layoutGraph", () => {
it("returns one position per step", () => {
const positions = layoutGraph(linearSteps, linearEdges);
expect(positions.length).toBe(linearSteps.length);
expect(new Set(positions.map((p) => p.id))).toEqual(new Set(["a", "b", "c"]));
});
it("produces strictly-increasing x positions for a linear chain (LR layout)", () => {
const positions = layoutGraph(linearSteps, linearEdges);
const byId = Object.fromEntries(positions.map((p) => [p.id, p.position]));
expect(byId.a.x).toBeLessThan(byId.b.x);
expect(byId.b.x).toBeLessThan(byId.c.x);
});
it("handles disconnected nodes without crashing", () => {
const positions = layoutGraph(
[
{ id: "x", name: "X", kind: "start", owner: "system", governs: [], state: "idle", actions: [] },
{ id: "y", name: "Y", kind: "end", owner: "system", governs: [], state: "idle", actions: [] },
],
[],
);
expect(positions.length).toBe(2);
});
});

37
src/graph/layout.ts Normal file
View File

@ -0,0 +1,37 @@
// dagre-based auto-layout for the process graph.
// Produces deterministic left-to-right DAG positions with consistent spacing
// regardless of how many nodes the live scenario has.
import dagre from "dagre";
import type { FlowEdge, ProcessStep } from "../data/types";
export interface PositionedNode {
id: string;
position: { x: number; y: number };
}
const NODE_W = 256;
const NODE_H = 100;
const RANK_SEP = 90;
const NODE_SEP = 36;
export function layoutGraph(steps: ProcessStep[], edges: FlowEdge[]): PositionedNode[] {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: "LR", ranksep: RANK_SEP, nodesep: NODE_SEP, marginx: 30, marginy: 30 });
g.setDefaultEdgeLabel(() => ({}));
for (const s of steps) g.setNode(s.id, { width: NODE_W, height: NODE_H });
for (const e of edges) g.setEdge(e.source, e.target);
dagre.layout(g);
return steps.map((s) => {
const n = g.node(s.id);
// dagre gives centre; React Flow wants top-left.
return {
id: s.id,
position: { x: (n?.x ?? 0) - NODE_W / 2, y: (n?.y ?? 0) - NODE_H / 2 },
};
});
}
export const NODE_SIZE = { width: NODE_W, height: NODE_H };

682
src/index.css Normal file
View File

@ -0,0 +1,682 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Fira+Sans:wght@300;400;500;600;700;800&display=swap');
@import 'reactflow/dist/style.css';
/* =====================================================================
FlowMaster Mission Control design system
===================================================================== */
:root {
/* Surfaces */
--bg: #06080f;
--bg-deep: #03050b;
--surface: #0f1626;
--surface-2: #151e33;
--surface-3: #1c2742;
--border: #243049;
--border-strong: #324166;
--border-glow: #3b82f644;
/* Text */
--text: #e6edf7;
--text-2: #95a3bf;
--text-3: #5e6c8a;
--text-soft: #c6d2e7;
/* Brand + state */
--primary: #3b82f6;
--primary-deep: #1e40af;
--primary-soft: rgba(59,130,246,0.18);
--accent: #d97706;
--ok: #34d399;
--run: #3b82f6;
--queue: #d97706;
--block: #f05252;
--idle: #5e6c8a;
--syn: #a855f7;
--ring: #3b82f6;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--radius-xl: 22px;
--mono: 'Fira Code', ui-monospace, monospace;
--sans: 'Fira Sans', system-ui, sans-serif;
--shadow-soft: 0 8px 28px rgba(0,0,0,0.45);
--shadow-lift: 0 16px 60px rgba(0,0,0,0.55);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 13.5px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
button { font-family: inherit; cursor: pointer; border: 0; background: none; color: inherit; }
button:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 6px; }
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 4px; }
kbd { font-family: var(--mono); font-size: 11px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--border); color: var(--text-2); }
/* =====================================================================
Shell
===================================================================== */
.shell { display: grid; grid-template-rows: auto 1fr; height: 100%; min-height: 0; }
.shell-landing { grid-template-rows: 1fr; }
.scene { min-height: 0; overflow: hidden; }
/* =====================================================================
Topbar
===================================================================== */
.topbar {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 18px;
padding: 0 18px;
height: 56px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
}
.brand-lock {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 700; letter-spacing: 0.2px;
}
.brand-btn { padding: 4px 6px; border-radius: 8px; }
.brand-btn:hover { background: var(--surface-2); }
.brand-mark {
width: 18px; height: 18px; border-radius: 6px;
background:
radial-gradient(circle at 30% 30%, rgba(255,255,255,0.4), transparent 60%),
linear-gradient(135deg, #3b82f6 0%, #a855f7 100%);
box-shadow: 0 0 12px rgba(59,130,246,0.5);
}
.brand-mark.sm { width: 14px; height: 14px; border-radius: 5px; }
.brand-name { font-weight: 800; letter-spacing: -0.2px; }
.brand-divider { width: 1px; height: 14px; background: var(--border); }
.brand-sub { color: var(--text-2); font-weight: 500; }
.tabs { display: inline-flex; gap: 4px; background: var(--surface-2); border: 1px solid var(--border); padding: 3px; border-radius: 10px; }
.tab {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 11px; border-radius: 7px; font-size: 12px;
color: var(--text-2); transition: background .18s, color .18s;
}
.tab:hover { color: var(--text); }
.tab-sel { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; }
.topbar-mid { display: flex; justify-content: flex-start; min-width: 0; }
.topbar-context { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; min-width: 0; }
.topbar-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px; border-radius: 999px; font-size: 12px;
background: var(--surface-2); border: 1px solid var(--border); color: var(--text-2);
max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.topbar-actions { display: inline-flex; gap: 8px; align-items: center; }
.link-btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 10px; border-radius: 8px;
border: 1px solid var(--border-strong); background: var(--surface-2);
color: var(--text-2); font-size: 12px; transition: border-color .18s, color .18s, background .18s;
}
.link-btn:hover:not(:disabled) { color: var(--text); border-color: var(--primary); background: var(--surface-3); }
.link-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* =====================================================================
Mission Control scene
===================================================================== */
.mc { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; }
.mc-strip {
display: flex; gap: 2px; padding: 8px 14px 0;
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.mc-tab {
display: inline-flex; align-items: center; gap: 8px;
padding: 9px 14px; border-radius: 10px 10px 0 0;
border: 1px solid transparent; border-bottom: 0;
color: var(--text-2); font-weight: 500; white-space: nowrap;
transition: background .15s, color .15s;
}
.mc-tab:hover { background: var(--surface-2); color: var(--text); }
.mc-tab-sel {
background: var(--bg); color: var(--text);
border-color: var(--border);
box-shadow: 0 -2px 0 var(--accent, --primary) inset;
border-bottom: 1px solid var(--bg);
margin-bottom: -1px;
}
.mc-tab-sel { box-shadow: inset 0 -2px 0 var(--accent); }
.mc-tab-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.mc-tab-label { font-size: 13px; }
.mc-tab-mark { font-size: 10px; padding: 1px 6px; border-radius: 4px; letter-spacing: 0.4px; text-transform: uppercase; font-weight: 600; }
.mc-tab-mark.is-live { background: rgba(52,211,153,0.18); color: var(--ok); border: 1px solid rgba(52,211,153,0.4); }
.mc-tab-mark.is-syn { background: rgba(168,85,247,0.16); color: var(--syn); border: 1px solid rgba(168,85,247,0.4); }
.mc-hero {
display: grid; grid-template-columns: 1fr auto;
align-items: center; gap: 16px;
padding: 16px 22px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.mc-hero-eyebrow { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 1.2px; }
.mc-hero-title { margin: 4px 0 2px; font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
.mc-hero-sub { color: var(--text-2); font-size: 13px; }
.mc-hero-kpis { display: inline-flex; gap: 8px; }
.mc-kpi {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 8px 14px; min-width: 92px; text-align: right;
}
.mc-kpi-v { font-size: 18px; font-weight: 700; font-family: var(--mono); }
.mc-kpi-l { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.8px; margin-top: 2px; }
.mc-body {
display: grid;
grid-template-columns: 320px 1fr 380px;
min-height: 0;
}
.mc-main { position: relative; min-width: 0; background:
radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0) 0 0 / 28px 28px,
var(--bg); }
/* =====================================================================
LeftRail
===================================================================== */
.left-rail {
border-right: 1px solid var(--border);
background: var(--bg-deep);
overflow-y: auto;
min-height: 0;
}
.panel { padding: 14px; border-bottom: 1px solid var(--border); }
.panel-h {
margin: 0 0 12px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase;
color: var(--text-3); font-weight: 700; display: flex; align-items: center; gap: 7px;
}
.panel-count { margin-left: auto; color: var(--text-2); background: var(--surface-2); border-radius: 999px; padding: 1px 8px; font-size: 11px; letter-spacing: 0; font-weight: 500; }
.kpi-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.kpi {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 12px;
}
.kpi-v { font-size: 18px; font-weight: 700; font-family: var(--mono); display: inline-flex; align-items: center; gap: 4px; }
.kpi-l { font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.7px; margin-top: 2px; }
.kpi-t { font-size: 10px; color: var(--text-2); margin-top: 1px; }
.cards { display: flex; flex-direction: column; gap: 8px; }
.qcard {
display: block; width: 100%; text-align: left;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 12px; color: var(--text);
transition: border-color .18s, background .18s, transform .18s;
}
.qcard:hover { border-color: var(--border-strong); background: var(--surface-2); transform: translateY(-1px); }
.qcard-sel { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary) inset; }
.qcard-title { font-weight: 500; font-size: 13px; }
.qcard-meta { display: flex; gap: 10px; align-items: center; margin-top: 6px; color: var(--text-2); font-size: 12px; }
.qcard-status.is-err { color: var(--block); }
.qcard-age { display: inline-flex; align-items: center; gap: 4px; }
.agent { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; margin-bottom: 9px; }
.agent-row { display: flex; align-items: center; gap: 8px; }
.agent-step { color: var(--text-2); font-size: 12px; }
.agent-intent { color: var(--text); font-size: 12.5px; margin: 7px 0 10px; }
.agent-acts { display: flex; gap: 8px; }
/* =====================================================================
Tag / pill / dot
===================================================================== */
.tag {
display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; font-weight: 600; padding: 2px 8px;
border-radius: 5px; letter-spacing: 0.4px; text-transform: uppercase;
}
.tag-approval { background: rgba(217,119,6,0.14); color: #f5b755; border: 1px solid rgba(217,119,6,0.3); }
.tag-agent { background: rgba(59,130,246,0.14); color: #7eb0ff; border: 1px solid rgba(59,130,246,0.3); }
.tag-input { background: rgba(94,108,138,0.16); color: var(--text-2); border: 1px solid var(--border-strong); }
.tag-live { background: rgba(52,211,153,0.16); color: var(--ok); border: 1px solid rgba(52,211,153,0.4); }
.tag-syn { background: rgba(168,85,247,0.16); color: var(--syn); border: 1px solid rgba(168,85,247,0.4); }
.pill {
display: inline-flex; align-items: center;
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 5px;
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text-2);
}
.pill-done { background: rgba(52,211,153,0.16); color: var(--ok); border-color: rgba(52,211,153,0.36); }
.pill-running { background: rgba(59,130,246,0.16); color: #7eb0ff; border-color: rgba(59,130,246,0.36); }
.pill-errored { background: rgba(240,82,82,0.16); color: var(--block); border-color: rgba(240,82,82,0.4); }
.pill-queued { background: rgba(217,119,6,0.14); color: #f5b755; border-color: rgba(217,119,6,0.36); }
.pill-blocked { background: rgba(240,82,82,0.14); color: var(--block); border-color: rgba(240,82,82,0.4); }
.pill-idle { background: var(--surface-2); color: var(--text-2); }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
.dot-done { background: var(--ok); }
.dot-running { background: var(--run); box-shadow: 0 0 8px var(--run); animation: pulse 1.6s ease-in-out infinite; }
.dot-queued { background: var(--queue); }
.dot-errored, .dot-blocked { background: var(--block); }
.dot-idle { background: var(--idle); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .dot-running { animation: none; } * { transition: none !important; animation: none !important; } }
/* =====================================================================
Buttons
===================================================================== */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 7px 11px; border-radius: 8px; font-size: 12px; font-weight: 600;
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text);
transition: background .15s, border-color .15s, transform .15s;
}
.btn:hover:not(:disabled) { border-color: var(--primary); background: var(--surface-3); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: linear-gradient(180deg, #3b82f6, #2563eb); border-color: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: linear-gradient(180deg, #4d8df8, #2f6cee); }
.btn-ghost { background: transparent; }
.btn-lg { padding: 11px 18px; font-size: 14px; }
/* =====================================================================
Process graph
===================================================================== */
.graph-canvas { position: relative; width: 100%; height: 100%; min-height: 0; }
.graph-overlay {
position: absolute; top: 14px; left: 14px; z-index: 4;
background: rgba(15,22,38,0.78); backdrop-filter: blur(8px);
border: 1px solid var(--border); border-radius: 10px; padding: 6px 10px;
font-size: 12px; color: var(--text-2);
}
.graph-overlay-row { display: inline-flex; align-items: center; gap: 8px; }
.graph-overlay-name { color: var(--text); font-weight: 600; }
.graph-overlay-sub { color: var(--text-2); }
.graph-overlay-dim { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
.node {
width: 256px; min-width: 256px; max-width: 256px;
background: linear-gradient(180deg, rgba(21,30,51,0.92), rgba(15,22,38,0.92));
backdrop-filter: blur(6px);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 12px;
color: var(--text);
transition: border-color .18s, box-shadow .18s, transform .18s;
}
.node:hover { border-color: var(--border-strong); }
.node-sel { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary) inset, 0 12px 28px rgba(59,130,246,0.22); }
.node-row { display: flex; align-items: center; gap: 8px; }
.node-name { font-weight: 600; font-size: 13px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; color: var(--text-2); font-size: 11px; }
.node-kind {
font-size: 9.5px; letter-spacing: 0.6px; text-transform: uppercase;
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px; color: var(--text-3);
}
.node-kind-human { color: #c8b6ff; border-color: rgba(168,85,247,0.4); }
.node-kind-agent { color: #7eb0ff; border-color: rgba(59,130,246,0.4); }
.node-kind-service { color: #5eead4; border-color: rgba(20,184,166,0.4); }
.node-kind-start, .node-kind-end { color: #fef3c7; border-color: rgba(217,119,6,0.4); }
.node-owner { color: var(--text-2); }
.node-gov { display: inline-flex; align-items: center; gap: 3px; color: #f5b755; }
.node-handle { background: var(--border-strong); width: 8px; height: 8px; border: 0; }
.node-dot { width: 8px; height: 8px; border-radius: 50%; }
.node-dot-done { background: var(--ok); }
.node-dot-running { background: var(--run); box-shadow: 0 0 8px var(--run); animation: pulse 1.6s ease-in-out infinite; }
.node-dot-queued { background: var(--queue); }
.node-dot-errored, .node-dot-blocked { background: var(--block); }
.node-dot-idle { background: var(--idle); }
.node-running {
box-shadow: 0 0 0 1px var(--run) inset, 0 8px 36px rgba(59,130,246,0.22);
border-color: var(--run);
}
.node-errored {
box-shadow: 0 0 0 1px var(--block) inset;
border-color: var(--block);
}
.node-done { opacity: 0.85; }
/* ReactFlow style overrides */
.react-flow__controls { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.react-flow__controls-button { background: var(--surface); border-bottom: 1px solid var(--border); color: var(--text-2); }
.react-flow__controls-button:hover { background: var(--surface-2); color: var(--text); }
.react-flow__attribution { display: none; }
/* =====================================================================
Inspector
===================================================================== */
.inspector {
border-left: 1px solid var(--border);
background: var(--bg-deep);
display: flex; flex-direction: column;
min-height: 0;
}
.inspector-head { padding: 14px 14px 10px; border-bottom: 1px solid var(--border); }
.inspector-eyebrow { color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 1.1px; font-weight: 600; }
.inspector-title h3 { margin: 4px 0 6px; font-size: 16px; font-weight: 700; }
.inspector-sub { color: var(--text-2); font-size: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.inspector-tabs { display: flex; gap: 3px; margin-top: 12px; background: var(--surface-2); padding: 3px; border-radius: 8px; border: 1px solid var(--border); }
.itab {
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px;
padding: 5px 6px; border-radius: 6px; font-size: 11px;
color: var(--text-2); transition: color .15s, background .15s;
}
.itab:hover { color: var(--text); }
.itab-sel { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; }
.inspector-body { padding: 12px 14px 18px; overflow-y: auto; flex: 1; min-height: 0; }
.i-section { display: flex; flex-direction: column; gap: 6px; }
.i-field { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
.i-field span:first-child { color: var(--text-2); }
.i-field span:last-child { text-align: right; }
.i-h { margin: 14px 0 6px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--text-3); font-weight: 700; }
.i-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.rule { background: var(--surface); border: 1px solid var(--border); border-left: 2px solid var(--accent); border-radius: 8px; padding: 9px 11px; }
.rule-head { display: flex; align-items: center; gap: 7px; color: var(--text); font-weight: 600; font-size: 12.5px; }
.rule-expr { color: var(--text-2); font-size: 12px; margin-top: 5px; }
.evt { display: flex; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border); }
.evt-ts { color: var(--text-3); font-size: 11px; min-width: 48px; }
.evt-who { color: #7eb0ff; font-size: 11px; }
.evt-sum { font-size: 12.5px; margin-top: 2px; color: var(--text-soft); }
.run { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; }
.run-head { display: flex; align-items: center; gap: 8px; }
.run-step { color: var(--text-2); font-size: 12px; margin-left: auto; }
.run-sub { color: var(--text-3); font-size: 11px; margin-top: 4px; }
.raw-json {
margin: 0; padding: 12px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text-2); font-family: var(--mono); font-size: 11.5px;
line-height: 1.5; overflow-x: auto; max-height: 60vh;
}
.empty { color: var(--text-3); font-size: 12.5px; padding: 18px 2px; text-align: center; }
/* =====================================================================
Telemetry strip
===================================================================== */
.telemetry {
display: flex; align-items: center; gap: 18px;
padding: 8px 18px; border-top: 1px solid var(--border);
background: linear-gradient(0deg, var(--surface) 0%, var(--bg) 100%);
min-height: 44px; font-size: 12px; color: var(--text-2);
}
.t-block { display: inline-flex; align-items: center; gap: 7px; }
.t-eyebrow { color: var(--text-3); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.8px; display: inline-flex; align-items: center; gap: 4px; }
.t-v { color: var(--text); font-weight: 600; font-size: 13px; }
.t-divider { width: 1px; height: 22px; background: var(--border); }
.t-spacer { flex: 1; }
.spark { display: block; }
.gauge { background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.gauge-fill { height: 100%; transition: width .35s ease; }
.t-tick { width: 8px; height: 8px; border-radius: 50%; background: var(--run); box-shadow: 0 0 10px var(--run); animation: pulse 1.6s ease-in-out infinite; }
/* =====================================================================
Command palette
===================================================================== */
.cmd-overlay {
position: fixed; inset: 0; background: rgba(3,5,11,0.6);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 12vh; z-index: 200;
}
.cmd {
width: min(620px, 92vw);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 30px 80px rgba(0,0,0,0.7);
}
.cmd-input-row { display: flex; align-items: center; gap: 9px; padding: 13px 16px; border-bottom: 1px solid var(--border); color: var(--text-2); }
.cmd-input-row [cmdk-input] { flex: 1; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 14.5px; font-family: var(--sans); }
.kbd-hint { font-size: 10.5px; }
.cmd [cmdk-list] { max-height: 380px; overflow: auto; padding: 8px; }
.cmd [cmdk-group-heading] { color: var(--text-3); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; padding: 8px 10px 4px; font-weight: 700; }
.cmd [cmdk-item] { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--text); cursor: pointer; font-size: 13px; }
.cmd [cmdk-item][data-selected='true'] { background: var(--surface-2); box-shadow: 0 0 0 1px var(--primary) inset; }
.cmd-hint { margin-left: auto; color: var(--text-3); font-size: 11px; }
.cmd [cmdk-empty] { padding: 24px; text-align: center; color: var(--text-3); font-size: 13px; }
/* =====================================================================
Tour
===================================================================== */
.tour-card {
position: fixed; z-index: 250;
width: min(340px, 92vw);
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-deep) 100%);
border: 1px solid var(--border-strong);
border-radius: 14px;
padding: 14px 16px 13px;
box-shadow: 0 26px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border-glow);
}
.tour-anchor-graph { left: calc(320px + 32px); bottom: 86px; }
.tour-anchor-queue { left: 24px; bottom: 86px; }
.tour-anchor-inspector { right: 24px; bottom: 86px; }
.tour-anchor-topbar { right: 24px; top: 76px; }
.tour-anchor-command { right: 24px; top: 76px; }
.tour-anchor-telemetry { left: 50%; transform: translateX(-50%); bottom: 64px; }
.tour-head { display: flex; align-items: center; gap: 7px; color: var(--text-2); }
.tour-eyebrow { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.tour-close { margin-left: auto; padding: 4px; border-radius: 6px; color: var(--text-3); transition: color .15s, background .15s; }
.tour-close:hover { color: var(--text); background: var(--surface-2); }
.tour-title { margin: 8px 0 6px; font-size: 16px; font-weight: 700; letter-spacing: -0.2px; }
.tour-body { color: var(--text-2); font-size: 13px; margin: 0 0 12px; }
.tour-actions { display: flex; gap: 8px; }
.tour-actions .btn { flex: 1; }
/* =====================================================================
Landing
===================================================================== */
.landing { position: relative; height: 100%; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; overflow-y: auto; }
.landing-bg {
position: absolute; inset: 0;
background:
radial-gradient(circle at 12% 18%, rgba(59,130,246,0.22) 0%, transparent 40%),
radial-gradient(circle at 86% 76%, rgba(168,85,247,0.18) 0%, transparent 45%),
radial-gradient(circle at 60% 5%, rgba(20,184,166,0.10) 0%, transparent 35%),
var(--bg);
z-index: 0;
}
.landing-grid {
position: absolute; inset: 0; z-index: 0;
background:
linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px) 0 0/72px 72px,
linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px) 0 0/72px 72px;
mask-image: radial-gradient(circle at 50% 40%, black 0%, transparent 70%);
}
.landing-top {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 22px 32px;
}
.landing-main {
position: relative; z-index: 1;
padding: 30px 32px 60px;
max-width: 1200px; margin: 0 auto; width: 100%;
display: grid; gap: 36px;
}
.landing-hero { display: flex; flex-direction: column; gap: 14px; max-width: 760px; }
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 7px;
font-size: 11.5px; color: var(--text-2); padding: 5px 10px; border-radius: 999px;
border: 1px solid var(--border-strong); background: rgba(15,22,38,0.6); backdrop-filter: blur(6px);
width: max-content;
}
.hero-title {
font-size: clamp(38px, 5vw, 60px);
line-height: 1.05;
margin: 4px 0 2px;
font-weight: 800;
letter-spacing: -1.4px;
}
.hl {
background: linear-gradient(90deg, #7eb0ff 0%, #c8b6ff 60%, #5eead4 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-sub { color: var(--text-2); font-size: 16.5px; max-width: 640px; }
.hero-actions { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.hero-stats {
display: flex; gap: 22px; margin-top: 18px; padding-top: 18px;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.stat { display: inline-flex; align-items: baseline; gap: 7px; }
.stat-v { font-size: 22px; font-weight: 700; }
.stat-l { color: var(--text-3); font-size: 12px; text-transform: uppercase; letter-spacing: 0.8px; }
.landing-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.sc-card {
display: flex; flex-direction: column; gap: 6px; text-align: left;
background: linear-gradient(180deg, rgba(21,30,51,0.86), rgba(15,22,38,0.86));
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px 16px 14px;
color: var(--text);
position: relative;
transition: transform .18s, border-color .18s, box-shadow .18s;
overflow: hidden;
}
.sc-card::before {
content: ""; position: absolute; inset: 0;
background: radial-gradient(circle at 0 0, var(--sc-accent, var(--primary)) 0%, transparent 60%);
opacity: 0.08; pointer-events: none;
}
.sc-card:hover { transform: translateY(-3px); border-color: var(--sc-accent, var(--primary)); box-shadow: 0 18px 50px rgba(0,0,0,0.45); }
.sc-card-top { display: flex; align-items: center; gap: 8px; }
.sc-card-mark { width: 16px; height: 16px; border-radius: 5px; background: var(--sc-accent, var(--primary)); box-shadow: 0 0 14px var(--sc-accent, var(--primary)); }
.sc-card-title { margin: 4px 0 2px; font-size: 17px; font-weight: 700; letter-spacing: -0.2px; }
.sc-card-sub { color: var(--text-2); font-size: 12.5px; margin: 0 0 10px; }
.sc-card-meta { color: var(--text-3); font-size: 11.5px; display: inline-flex; gap: 6px; }
.sc-card-cta { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; color: var(--sc-accent, var(--primary)); font-weight: 600; font-size: 12px; }
.landing-foot { position: relative; z-index: 1; padding: 14px 32px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 11.5px; text-align: center; }
.foot-eyebrow { letter-spacing: 0.6px; }
/* =====================================================================
RunHistory
===================================================================== */
.rh { height: 100%; display: flex; flex-direction: column; min-height: 0; }
.rh-head { display: flex; align-items: end; justify-content: space-between; padding: 18px 24px; gap: 12px; border-bottom: 1px solid var(--border); background: var(--bg); }
.rh-filters { display: inline-flex; gap: 6px; }
.rh-chip {
padding: 5px 11px; border-radius: 999px; font-size: 12px;
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text-2);
text-transform: capitalize;
}
.rh-chip:hover { color: var(--text); border-color: var(--primary); }
.rh-chip-sel { background: var(--primary-deep); border-color: var(--primary); color: #fff; }
.rh-list { overflow-y: auto; padding: 8px 16px 24px; display: flex; flex-direction: column; gap: 4px; }
.rh-row {
display: grid; grid-template-columns: 200px 180px minmax(0, 1.3fr) minmax(160px, 1fr) auto;
align-items: center; gap: 14px;
padding: 10px 14px; border-radius: 10px;
border: 1px solid transparent;
font-size: 13px; color: var(--text);
}
.rh-row:hover { background: var(--surface-2); border-color: var(--border); }
.rh-row-id { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.rh-row-id .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.rh-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.rh-row-scenario { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.rh-row-step { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.rh-row-bar { background: var(--surface-2); border-radius: 999px; height: 8px; overflow: hidden; border: 1px solid var(--border); }
.rh-bar-fill { height: 100%; transition: width .3s ease; }
.rh-row-meta { display: inline-flex; align-items: center; gap: 8px; color: var(--text-2); }
/* Scrollbar */
.scene ::-webkit-scrollbar, .left-rail::-webkit-scrollbar, .inspector-body::-webkit-scrollbar, .rh-list::-webkit-scrollbar, .cmd [cmdk-list]::-webkit-scrollbar, .landing::-webkit-scrollbar {
width: 8px; height: 8px;
}
.scene ::-webkit-scrollbar-thumb, .left-rail::-webkit-scrollbar-thumb, .inspector-body::-webkit-scrollbar-thumb, .rh-list::-webkit-scrollbar-thumb, .cmd [cmdk-list]::-webkit-scrollbar-thumb, .landing::-webkit-scrollbar-thumb {
background: var(--border-strong); border-radius: 4px;
}
.scene ::-webkit-scrollbar-track, .left-rail::-webkit-scrollbar-track, .inspector-body::-webkit-scrollbar-track, .rh-list::-webkit-scrollbar-track, .cmd [cmdk-list]::-webkit-scrollbar-track, .landing::-webkit-scrollbar-track {
background: transparent;
}
/* =====================================================================
Live-mode pill / toggle / banners (added in v2 honesty pass)
===================================================================== */
.mode-pill {
display: inline-flex; align-items: center;
font-size: 10px; font-weight: 700; letter-spacing: 0.8px;
padding: 2px 8px; border-radius: 5px; text-transform: uppercase;
}
.mode-snapshot { background: rgba(168,85,247,0.16); color: var(--syn); border: 1px solid rgba(168,85,247,0.4); }
.mode-live { background: rgba(52,211,153,0.18); color: var(--ok); border: 1px solid rgba(52,211,153,0.45); }
.mode-toggle { gap: 8px; }
.mode-toggle.mode-live { border-color: rgba(52,211,153,0.45); }
.topbar-age { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin-left: 2px; }
.spin {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--border-strong); border-top-color: var(--primary);
animation: spin 0.8s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
.mc-banner {
display: flex; align-items: center; gap: 9px;
padding: 8px 18px; font-size: 12.5px;
border-bottom: 1px solid var(--border);
}
.mc-banner-info { background: rgba(59,130,246,0.08); color: #7eb0ff; }
.mc-banner-err { background: rgba(240,82,82,0.10); color: #ff8a8a; }
.mc-banner .mono { font-size: 12px; color: inherit; }
.preview-marker {
display: inline-block;
margin-left: 6px; padding: 0 6px;
font-size: 9.5px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase;
border: 1px solid rgba(168,85,247,0.45); color: var(--syn);
background: rgba(168,85,247,0.12);
border-radius: 4px;
}
.btn-decline { color: #ff9b9b; }
.btn-decline:hover:not(:disabled) { border-color: var(--block); }
.is-on { border-color: var(--ok); color: var(--ok); }
/* =====================================================================
Toaster
===================================================================== */
.toaster {
position: fixed; right: 18px; bottom: 18px; z-index: 300;
display: flex; flex-direction: column; gap: 8px;
width: min(380px, 92vw); pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex; align-items: center; gap: 9px;
padding: 10px 12px; border-radius: 10px;
background: var(--surface-2); border: 1px solid var(--border-strong);
box-shadow: 0 16px 50px rgba(0,0,0,0.45);
font-size: 12.5px; color: var(--text);
}
.toast-msg { flex: 1; line-height: 1.4; }
.toast-x { padding: 2px; color: var(--text-3); border-radius: 5px; }
.toast-x:hover { color: var(--text); background: var(--surface-3); }
.toast-info { border-left: 3px solid var(--primary); }
.toast-ok { border-left: 3px solid var(--ok); }
.toast-warn { border-left: 3px solid var(--queue); }
.toast-err { border-left: 3px solid var(--block); }

108
src/lib/api.test.ts Normal file
View File

@ -0,0 +1,108 @@
// Tests the in-browser API client against a stubbed fetch.
// We don't hit the real backend here — that's covered by the smoke test
// (`pnpm qa:smoke` toggles live mode and asserts the network call resolves).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { api } from "./api";
function mockFetch(handlers: Array<(url: string, init: RequestInit) => Response | undefined>) {
const calls: Array<{ url: string; init: RequestInit }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
for (const h of handlers) {
const r = h(url, init);
if (r) return r;
}
return new Response("not stubbed", { status: 500 });
}) as unknown as typeof fetch;
// sessionStorage stub
const store = new Map<string, string>();
globalThis.sessionStorage = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => { store.set(k, String(v)); },
removeItem: (k) => { store.delete(k); },
clear: () => store.clear(),
key: () => null,
length: 0,
} as Storage;
return calls;
}
beforeEach(() => {
// reset between tests
api.clearToken();
});
describe("api client", () => {
it("ping() returns ok with email on success", async () => {
mockFetch([
(url) => url.endsWith("/api/v1/auth/dev-login")
? new Response(JSON.stringify({ access_token: "T1" }), { status: 200, headers: { "Content-Type": "application/json" } })
: undefined,
(url) => url.endsWith("/api/v1/auth/me")
? new Response(JSON.stringify({ user_id: "u1", tenant_id: "t1", email: "dev@flow-master.ai" }), { status: 200, headers: { "Content-Type": "application/json" } })
: undefined,
]);
const r = await api.ping();
expect(r.ok).toBe(true);
expect(r.user).toBe("dev@flow-master.ai");
});
it("ping() returns ok=false with reason when login fails", async () => {
mockFetch([
(url) => url.endsWith("/api/v1/auth/dev-login")
? new Response("nope", { status: 401 })
: undefined,
]);
const r = await api.ping();
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/401/);
});
it("workItems() returns the items array", async () => {
mockFetch([
(url) => url.endsWith("/api/v1/auth/dev-login")
? new Response(JSON.stringify({ access_token: "T2" }), { status: 200 })
: undefined,
(url) => url.includes("/api/ea2/work-items")
? new Response(JSON.stringify({ items: [{ transaction_id: "tx1", status: "running", definition_key: "k1" }] }), { status: 200 })
: undefined,
]);
const items = await api.workItems();
expect(items.length).toBe(1);
expect(items[0].transaction_id).toBe("tx1");
});
it("graph() returns null on 404 (typed-safe failure mode)", async () => {
mockFetch([
(url) => url.endsWith("/api/v1/auth/dev-login")
? new Response(JSON.stringify({ access_token: "T3" }), { status: 200 })
: undefined,
(url) => url.includes("/api/ea2/process-definitions/")
? new Response("not found", { status: 404 })
: undefined,
]);
const g = await api.graph("does-not-exist");
expect(g).toBeNull();
});
it("re-logs in after 401 then succeeds", async () => {
let metHits = 0;
const calls = mockFetch([
(url) => url.endsWith("/api/v1/auth/dev-login")
? new Response(JSON.stringify({ access_token: `T${metHits + 1}` }), { status: 200 })
: undefined,
(url) => {
if (url.endsWith("/api/v1/auth/me")) {
metHits += 1;
if (metHits === 1) return new Response("expired", { status: 401 });
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
}
return undefined;
},
]);
const r = await api.ping();
expect(r.ok).toBe(true);
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login
});
});

174
src/lib/api.ts Normal file
View File

@ -0,0 +1,174 @@
// Real in-browser API client for demo.flow-master.ai.
// - dev-login → bearer (cached in sessionStorage, refresh on 401)
// - typed wrappers for the endpoints Mission Control needs
// - all fetches honor an AbortSignal so unmounts cancel cleanly
// - never throws into render: returns { ok, error } shapes
export interface ApiConfig {
baseUrl: string;
email: string;
}
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
// the build is deployed at the same origin as the backend, so an empty
// baseUrl is correct.
const isDev = import.meta.env.DEV;
const DEFAULT_CONFIG: ApiConfig = {
baseUrl: import.meta.env.VITE_FM_BASE || (isDev ? "" : "https://demo.flow-master.ai"),
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
};
const TOKEN_KEY = "fm.mc.token.v1";
let inflightLogin: Promise<string> | null = null;
async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
if (inflightLogin) return inflightLogin;
inflightLogin = (async () => {
const r = await fetch(`${cfg.baseUrl}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: cfg.email }),
signal,
});
if (!r.ok) throw new Error(`dev-login ${r.status}`);
const body = (await r.json()) as { access_token: string };
sessionStorage.setItem(TOKEN_KEY, body.access_token);
return body.access_token;
})();
try {
return await inflightLogin;
} finally {
inflightLogin = null;
}
}
async function withAuth<T>(
cfg: ApiConfig,
path: string,
init: RequestInit = {},
signal?: AbortSignal,
): Promise<T> {
let token = sessionStorage.getItem(TOKEN_KEY);
if (!token) token = await login(cfg, signal);
const doFetch = async () =>
fetch(`${cfg.baseUrl}${path}`, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal,
});
let r = await doFetch();
if (r.status === 401) {
sessionStorage.removeItem(TOKEN_KEY);
token = await login(cfg, signal);
r = await doFetch();
}
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`${path}${r.status} ${text.slice(0, 120)}`);
}
return (await r.json()) as T;
}
export interface WorkItem {
transaction_id: string;
short_id?: string;
status: string;
hub?: string;
age_days?: number;
active_step_display_name?: string;
current_node?: string;
next_action?: string;
definition_key: string;
business_subject?: string;
created_at?: string;
}
export interface ProcessGraph {
process_definition: {
_key: string;
name: string;
display_name?: string;
hub?: string;
config: { nodes: unknown[]; edges: unknown[]; org_id?: string };
};
version_definitions?: Array<{ version: number; approved_at?: string }>;
}
export interface RuntimeTransaction {
transaction_id: string;
status: string;
active_step?: { step_definition_id?: string; display_name?: string };
available_actions?: Array<{ display_label: string }>;
created_at?: string;
business_subject?: string;
}
export interface AuthMe {
user_id: string;
tenant_id: string;
email: string;
display_name?: string;
}
export const api = {
config: DEFAULT_CONFIG,
async me(signal?: AbortSignal): Promise<AuthMe> {
return withAuth<AuthMe>(this.config, "/api/v1/auth/me", {}, signal);
},
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
const body = await withAuth<{ items?: WorkItem[] }>(
this.config,
"/api/ea2/work-items?view=all",
{},
signal,
);
return body.items ?? [];
},
async graph(defKey: string, signal?: AbortSignal): Promise<ProcessGraph | null> {
try {
return await withAuth<ProcessGraph>(
this.config,
`/api/ea2/process-definitions/${defKey}/graph`,
{},
signal,
);
} catch {
return null;
}
},
async transaction(txId: string, signal?: AbortSignal): Promise<RuntimeTransaction | null> {
try {
return await withAuth<RuntimeTransaction>(
this.config,
`/api/runtime/transactions/${txId}`,
{},
signal,
);
} catch {
return null;
}
},
/** Probe whether the backend is reachable. Never throws. */
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string }> {
try {
const me = await this.me(signal);
return { ok: true, user: me.email };
} catch (e) {
return { ok: false, reason: (e as Error).message };
}
},
clearToken() {
sessionStorage.removeItem(TOKEN_KEY);
},
};

312
src/lib/buildScenarios.ts Normal file
View File

@ -0,0 +1,312 @@
// In-browser equivalent of fetch_scenarios.mjs:
// pulls live work items → groups by definition_key → fetches each graph and
// representative runtime → maps into ProcessScenario[] using the same shape
// as the bundled snapshot loader (`data/live.ts`).
import { api, type WorkItem, type ProcessGraph, type RuntimeTransaction } from "./api";
import type {
AgentRun, EvidenceItem, FlowEdge, ProcessScenario, ProcessStep,
QueueItem, Rule, RuntimeState, RunSummary, StepAction, StepKind, TourStep,
} from "../data/types";
const KIND_FROM_TYPE: Record<string, StepKind> = {
start: "start",
end: "end",
human_task: "human",
agent_task: "agent",
service_task: "service",
system_task: "service",
};
const OWNER_FROM_TYPE: Record<string, "human" | "agent" | "system"> = {
start: "system",
end: "system",
human_task: "human",
agent_task: "agent",
service_task: "system",
system_task: "system",
};
const WAIT_FROM_STATUS: Record<string, QueueItem["waitingOn"]> = {
running: "approval",
waiting_for_user: "approval",
waiting_for_agent: "agent",
errored: "input",
failed: "input",
};
const shortNode = (defKey: string, id: string | null | undefined): string | null =>
id ? id.replace(`${defKey}_`, "") : null;
interface LiveNode {
id: string;
type: string;
label?: string;
display_name?: string;
actions?: Array<{ id: string; display_label?: string; label?: string; kind: string }>;
}
interface LiveEdge {
id: string;
source: string;
target: string;
outcome?: string;
}
function buildScenarioFromGraph(
family: ProcessScenario["family"],
defKey: string,
graph: ProcessGraph,
cases: WorkItem[],
headlineRt: RuntimeTransaction | null,
recent: RuntimeTransaction[],
): ProcessScenario {
const pd = graph.process_definition;
const cfg = pd.config as { nodes: LiveNode[]; edges: LiveEdge[] };
const activeNode = headlineRt ? shortNode(defKey, headlineRt.active_step?.step_definition_id) : null;
const activeIdx = cfg.nodes.findIndex((n) => n.id === activeNode);
const rtStatus = headlineRt?.status ?? "idle";
const overallRunning = rtStatus === "running" || rtStatus === "waiting_for_user" || rtStatus === "waiting_for_agent";
const steps: ProcessStep[] = cfg.nodes.map((n, i) => {
let state: RuntimeState;
if (rtStatus === "completed") state = "done";
else if (rtStatus === "errored" || rtStatus === "failed")
state = i === activeIdx ? "errored" : i < activeIdx ? "done" : "idle";
else if (overallRunning) {
if (n.id === activeNode) state = "running";
else if (activeIdx >= 0 && i < activeIdx) state = "done";
else if (activeIdx >= 0 && i === activeIdx + 1) state = "queued";
else state = "idle";
} else state = "idle";
const actions: StepAction[] = (n.actions || []).map((a) => ({
id: a.id,
label: a.display_label || a.label || a.kind,
kind: (a.kind === "approve" || a.kind === "decline" || a.kind === "fork" ? a.kind : "complete") as StepAction["kind"],
}));
return {
id: n.id,
name: n.label || n.display_name || n.id,
kind: KIND_FROM_TYPE[n.type] || "service",
owner: OWNER_FROM_TYPE[n.type] || "human",
governs: n.type === "human_task" ? ["spend-threshold"] : [],
state,
actions,
raw: n,
};
});
const stepById = new Map(steps.map((s) => [s.id, s]));
const edges: FlowEdge[] = cfg.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.outcome,
traversed: stepById.get(e.source)?.state === "done",
}));
const rules: Rule[] = steps.some((s) => s.governs.length)
? [{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true }]
: [];
const evidence: EvidenceItem[] = activeNode
? [
{
id: "ev-rt",
stepId: activeNode,
at: (headlineRt?.created_at || "").slice(11, 16) || "now",
actor: "runtime",
summary: `Transaction ${headlineRt?.transaction_id?.slice(0, 8)} active at ${headlineRt?.active_step?.display_name ?? activeNode}`,
},
]
: [];
const queue: QueueItem[] = cases.slice(0, 8).map((c, i) => ({
id: `${c.transaction_id || defKey}-${i}`,
stepId: shortNode(defKey, c.current_node) || activeNode || steps[0]?.id || "",
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
waitingOn: WAIT_FROM_STATUS[c.status] ?? "input",
ageDays: c.age_days ?? 0,
status: c.status,
}));
const agentRuns: AgentRun[] = activeNode
? [
{
id: "agent-1",
stepId: activeNode,
status: "awaiting-confirm",
intent: `Action "${headlineRt?.available_actions?.[0]?.display_label ?? "Complete"}" via sidekick_on_behalf_of_user`,
isSynthetic: true,
},
]
: [];
const seenRunIds = new Set<string>();
const runs: RunSummary[] = [headlineRt, ...recent].filter(Boolean).flatMap((rt) => {
const r = rt as RuntimeTransaction;
if (seenRunIds.has(r.transaction_id)) return [];
seenRunIds.add(r.transaction_id);
const started = r.created_at ? new Date(r.created_at).getTime() : Date.now();
return [{
id: r.transaction_id,
shortId: r.transaction_id.slice(0, 8),
activeStep: r.active_step?.display_name ?? null,
status: r.status,
startedAt: r.created_at ?? "",
durationSec: Math.max(60, Math.floor((Date.now() - started) / 1000)),
}];
});
const statuses: Record<string, number> = {};
for (const c of cases) statuses[c.status] = (statuses[c.status] || 0) + 1;
const kpis = [
{ label: "Live cases", value: String(cases.length), trend: "up" as const, trendValue: `${Math.min(3, cases.length)} now` },
{ label: "Running", value: String(statuses.running ?? 0), trend: "flat" as const },
{ label: "Errored", value: String((statuses.errored ?? 0) + (statuses.failed ?? 0)), trend: (statuses.errored ?? 0) > 0 ? ("up" as const) : ("flat" as const) },
{ label: "Avg cycle", value: avgCycle(cases), trend: "down" as const, trendValue: "" },
];
const defaultStepId = activeNode || steps[0]?.id || "";
const versionLabel = `v${graph.version_definitions?.[0]?.version ?? 1}`;
return {
id: family.id,
family,
live: true,
defKey,
defName: pd.display_name || pd.name,
version: versionLabel,
headlineTx: headlineRt?.transaction_id ?? null,
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
steps,
edges,
rules,
evidence,
queue,
agentRuns,
runs,
kpis,
defaultStepId,
tour: buildTour(family.id, defaultStepId),
raw: { graph, headlineRt },
};
}
function avgCycle(cases: WorkItem[]): string {
const ages = cases.map((c) => c.age_days ?? 0).filter((a) => a > 0);
if (!ages.length) return "—";
const avg = ages.reduce((s, a) => s + a, 0) / ages.length;
return `${avg.toFixed(1)}d`;
}
function buildTour(familyId: string, defaultStepId: string): TourStep[] {
return [
{ id: "t1", anchor: "graph", title: "Live process at a glance", body: "This graph is the real, executing definition for this scenario. Each node maps to a runtime step the backend is driving.", selectStep: defaultStepId },
{ id: "t2", anchor: "queue", title: "Real cases, real states", body: "The left rail mirrors the live EA2 work-item board for this definition. Counts, statuses, and ages come straight from the runtime API." },
{ id: "t3", anchor: "inspector", title: "Typed inspector", body: "Click any step to see its typed fields, governing rules, and evidence trail. The Raw tab gives you the EA2 payload verbatim." },
{ id: "t4", anchor: "command", title: "⌘K command palette", body: `Press ⌘K to jump between scenarios, steps, or open the guided tour for ${familyId}.` },
{ id: "t5", anchor: "telemetry", title: "Throughput rollup", body: "The bottom strip rolls up running/errored cases across every scenario you have open." },
{ id: "t6", anchor: "graph", title: "Explore", body: "That's the full loop. Switch scenarios at the top, or open ⌘K to navigate freely." },
];
}
interface CandidateBucket {
key: string;
cases: WorkItem[];
statuses: Record<string, number>;
hubs: Set<string>;
}
const FAMILIES = [
{ id: "procurement", label: "Procurement to Pay", subtitle: "Requisition → PO → 3-way match", accent: "#3b82f6", re: /procure|purchas|pr_to_po|atlas|requisition|po\b/i },
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#10b981", re: /refund|credit|collect|receivable|invoice/i },
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#a855f7", re: /onboard|offboard|hire|hcm|employee|leave|payroll|hr\b/i },
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#f59e0b", re: /close|ledger|journal|accrual|reconcil|gl\b/i },
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#ef4444", re: /ticket|incident|support|service|case\b/i },
];
export async function buildLiveScenariosFromApi(signal?: AbortSignal): Promise<{ scenarios: ProcessScenario[]; workItems: WorkItem[]; distinctDefs: number }> {
const workItems = await api.workItems(signal);
// Bucket by definition_key with meta.
const byDef = new Map<string, CandidateBucket>();
for (const w of workItems) {
const k = w.definition_key;
if (!k) continue;
if (!byDef.has(k)) byDef.set(k, { key: k, cases: [], statuses: {}, hubs: new Set() });
const b = byDef.get(k)!;
b.cases.push(w);
b.statuses[w.status] = (b.statuses[w.status] || 0) + 1;
if (w.hub) b.hubs.add(w.hub);
}
// Candidates with ≥ 2 cases OR 1 running.
const candidates = [...byDef.values()].filter((d) => d.cases.length >= 2 || (d.statuses.running ?? 0) >= 1);
candidates.sort((a, b) => b.cases.length - a.cases.length);
// For each candidate fetch graph + a couple runtimes.
interface Enriched { bucket: CandidateBucket; graph: ProcessGraph; headlineRt: RuntimeTransaction | null; recent: RuntimeTransaction[] }
const enriched: Enriched[] = [];
for (const c of candidates.slice(0, 20)) {
if (signal?.aborted) break;
const graph = await api.graph(c.key, signal);
if (!graph?.process_definition?.config?.nodes?.length) continue;
const headlineCase =
c.cases.find((w) => w.status === "running") ||
c.cases.find((w) => w.status === "waiting_for_user") ||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
c.cases[0];
const headlineRt = headlineCase?.transaction_id ? await api.transaction(headlineCase.transaction_id, signal) : null;
const recent: RuntimeTransaction[] = [];
for (const w of c.cases.slice(0, 6)) {
if (signal?.aborted) break;
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
const r = await api.transaction(w.transaction_id, signal);
if (r) recent.push(r);
if (recent.length >= 3) break;
}
enriched.push({ bucket: c, graph, headlineRt, recent });
}
// Classify into families.
const used = new Set<string>();
const scenarios: ProcessScenario[] = [];
for (const fam of FAMILIES) {
const pick = enriched
.filter((e) => !used.has(e.bucket.key) && fam.re.test(`${e.graph.process_definition.display_name ?? e.graph.process_definition.name} ${[...e.bucket.hubs].join(" ")}`))
.sort((a, b) => b.bucket.cases.length - a.bucket.cases.length)[0];
if (!pick) continue;
used.add(pick.bucket.key);
scenarios.push(
buildScenarioFromGraph(
{ id: fam.id, label: fam.label, subtitle: fam.subtitle, accent: fam.accent },
pick.bucket.key,
pick.graph,
pick.bucket.cases,
pick.headlineRt,
pick.recent,
),
);
}
// Top-up with remaining largest buckets if we have fewer than 4.
const leftovers = enriched.filter((e) => !used.has(e.bucket.key)).sort((a, b) => b.bucket.cases.length - a.bucket.cases.length);
while (scenarios.length < 4 && leftovers.length) {
const e = leftovers.shift()!;
scenarios.push(
buildScenarioFromGraph(
{
id: `extra-${scenarios.length}`,
label: e.graph.process_definition.display_name || e.graph.process_definition.name,
subtitle: `${e.bucket.cases.length} live cases`,
accent: "#64748b",
},
e.bucket.key,
e.graph,
e.bucket.cases,
e.headlineRt,
e.recent,
),
);
used.add(e.bucket.key);
}
return { scenarios, workItems, distinctDefs: byDef.size };
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

8138
src/scenarios.json Normal file

File diff suppressed because it is too large Load Diff

132
src/scenes/Landing.tsx Normal file
View File

@ -0,0 +1,132 @@
// Cinematic landing scene: brand, scenarios pick, start tour CTA.
import { motion } from "framer-motion";
import { useApp } from "../state/store";
import { liveMeta } from "../data/scenarios";
import { Sparkles, Arrow, Cmd, Bot, Pulse } from "../components/icons";
export default function Landing() {
const setScene = useApp((s) => s.setScene);
const setScenarioId = useApp((s) => s.setScenarioId);
const startTour = useApp((s) => s.startTour);
const setCmdOpen = useApp((s) => s.setCmdOpen);
const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const liveLoading = useApp((s) => s.liveLoading);
const liveTotals = useApp((s) => s.liveTotals);
return (
<div className="landing">
<div className="landing-bg" aria-hidden />
<div className="landing-grid" aria-hidden />
<header className="landing-top">
<div className="brand-lock">
<span className="brand-mark" />
<span className="brand-name">FlowMaster</span>
<span className="brand-divider" />
<span className="brand-sub">Mission Control</span>
</div>
<button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> Command <kbd>K</kbd>
</button>
</header>
<main className="landing-main">
<motion.div
className="landing-hero"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45 }}
>
<span className="hero-eyebrow"><Sparkles size={13} /> Business-as-code · Mission Control</span>
<h1 className="hero-title">
Every process. <span className="hl">One control surface.</span>
</h1>
<p className="hero-sub">
FlowMaster turns the operational map of a company into living,
typed processes backed by humans, agents, and rules and gives you a
single command-center to drive them. Procurement scenarios are real,
backed by EA2 on{" "}
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>;
AR, HCM, GL, and Service are industry blueprints showing how this
same shell extends to any process family.
</p>
<div className="hero-actions">
<button className="btn btn-primary btn-lg" onClick={startTour}>
<Sparkles size={14} /> Start guided tour
</button>
<button className="btn btn-ghost btn-lg" onClick={() => setScene("mission")}>
Skip to Mission Control <Arrow size={13} />
</button>
<button
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
disabled={liveLoading}
title="Toggle between bundled snapshot and a live in-browser fetch from demo.flow-master.ai"
>
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
{mode === "live" ? "Live mode · on" : "Go live"}
</button>
</div>
<div className="hero-stats">
<div className="stat">
<Pulse size={12} />
<span className="stat-v mono">{liveTotals?.workItems ?? liveMeta.workItems}</span>
<span className="stat-l">{mode === "live" ? "live work items (now)" : "snapshot work items"}</span>
</div>
<div className="stat">
<span className="stat-v mono">{liveTotals?.distinctDefs ?? liveMeta.distinctDefs}</span>
<span className="stat-l">process definitions</span>
</div>
<div className="stat">
<Bot size={12} />
<span className="stat-v mono">{scenarios.length}</span>
<span className="stat-l">scenarios in catalog</span>
</div>
<div className="stat">
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
<span className="stat-l">data mode</span>
</div>
</div>
</motion.div>
<motion.div
className="landing-cards"
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.45 }}
>
{scenarios.map((s) => (
<button
key={s.id}
className="sc-card"
style={{ ["--sc-accent" as string]: s.family.accent }}
onClick={() => { setScenarioId(s.id); setScene("mission"); }}
>
<div className="sc-card-top">
<span className="sc-card-mark" />
<span className={`tag ${s.live ? "tag-live" : "tag-syn"}`}>{s.live ? "live" : "blueprint"}</span>
</div>
<h3 className="sc-card-title">{s.family.label}</h3>
<p className="sc-card-sub">{s.family.subtitle}</p>
<div className="sc-card-meta">
<span>{s.steps.length} steps</span>
<span>·</span>
<span>{s.queue.length} cases</span>
<span>·</span>
<span className="mono">{s.version}</span>
</div>
<div className="sc-card-cta">Open <Arrow size={12} /></div>
</button>
))}
</motion.div>
</main>
<footer className="landing-foot">
<span className="foot-eyebrow">FlowMaster · Mission Control demo · synthesised on top of demo.flow-master.ai</span>
</footer>
</div>
);
}

View File

@ -0,0 +1,72 @@
// MissionControl scene: scenario tabs + graph + rails + telemetry strip.
import { useApp, scenarioById } from "../state/store";
import ProcessGraph from "../components/ProcessGraph";
import LeftRail from "../components/LeftRail";
import Inspector from "../components/Inspector";
import Telemetry from "../components/Telemetry";
export default function MissionControl() {
const scenarioId = useApp((s) => s.scenarioId);
const setScenarioId = useApp((s) => s.setScenarioId);
const scenarios = useApp((s) => s.scenarios);
const liveLoading = useApp((s) => s.liveLoading);
const liveError = useApp((s) => s.liveError);
const sc = scenarioById(scenarioId);
return (
<div className="mc">
{liveLoading && (
<div className="mc-banner mc-banner-info">
<span className="spin" /> Fetching live scenarios from demo.flow-master.ai
</div>
)}
{liveError && (
<div className="mc-banner mc-banner-err">
Live mode failed: <span className="mono">{liveError}</span> · showing snapshot
</div>
)}
<div className="mc-strip" role="tablist" aria-label="Scenarios">
{scenarios.map((s) => (
<button
key={s.id}
role="tab"
aria-selected={s.id === scenarioId}
className={`mc-tab${s.id === scenarioId ? " mc-tab-sel" : ""}`}
style={{ ["--accent" as string]: s.family.accent }}
onClick={() => setScenarioId(s.id)}
>
<span className="mc-tab-dot" />
<span className="mc-tab-label">{s.family.label}</span>
<span className={`mc-tab-mark ${s.live ? "is-live" : "is-syn"}`}>{s.live ? "live" : "bp"}</span>
</button>
))}
</div>
<div className="mc-hero">
<div>
<div className="mc-hero-eyebrow">{sc?.family.subtitle}</div>
<h2 className="mc-hero-title">{sc?.defName}</h2>
<div className="mc-hero-sub">{sc?.tagline}</div>
</div>
<div className="mc-hero-kpis">
{sc?.kpis.slice(0, 4).map((k) => (
<div className="mc-kpi" key={k.label}>
<div className="mc-kpi-v">{k.value}</div>
<div className="mc-kpi-l">{k.label}</div>
</div>
))}
</div>
</div>
<div className="mc-body">
<LeftRail />
<main className="mc-main">
<ProcessGraph />
</main>
<Inspector />
</div>
<Telemetry />
</div>
);
}

81
src/scenes/RunHistory.tsx Normal file
View File

@ -0,0 +1,81 @@
// RunHistory scene: cross-scenario timeline.
import { useMemo, useState } from "react";
import { useApp } from "../state/store";
import { Clock, History } from "../components/icons";
const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
type Status = (typeof STATUS_FILTERS)[number];
const STATUS_COLOR: Record<string, string> = {
running: "var(--run)",
completed: "var(--ok)",
done: "var(--ok)",
errored: "var(--block)",
failed: "var(--block)",
queued: "var(--queue)",
};
export default function RunHistory() {
const [filter, setFilter] = useState<Status>("all");
const scenarios = useApp((s) => s.scenarios);
const rows = useMemo(() => {
const out: Array<{ scenarioId: string; scenarioLabel: string; accent: string; live: boolean; run: (typeof scenarios)[number]["runs"][number] }> = [];
for (const s of scenarios) {
for (const r of s.runs) {
if (filter !== "all" && r.status !== filter && !(filter === "completed" && r.status === "done")) continue;
out.push({ scenarioId: s.id, scenarioLabel: s.family.label, accent: s.family.accent, live: s.live, run: r });
}
}
return out.sort((a, b) => (b.run.startedAt || "").localeCompare(a.run.startedAt || ""));
}, [filter]);
const maxDuration = useMemo(() => Math.max(60, ...rows.map((r) => r.run.durationSec)), [rows]);
return (
<div className="rh">
<header className="rh-head">
<div>
<span className="mc-hero-eyebrow"><History size={12} /> Run history</span>
<h2 className="mc-hero-title">All scenarios · all runs</h2>
</div>
<nav className="rh-filters">
{STATUS_FILTERS.map((f) => (
<button key={f} className={`rh-chip${filter === f ? " rh-chip-sel" : ""}`} onClick={() => setFilter(f)}>
{f}
</button>
))}
</nav>
</header>
<div className="rh-list">
{rows.length === 0 && <div className="empty">No runs match the current filter.</div>}
{rows.map((row) => (
<div className="rh-row" key={`${row.scenarioId}-${row.run.id}`}>
<div className="rh-row-id">
<span className="rh-dot" style={{ background: row.accent }} />
<span className="mono">{row.run.shortId}</span>
<span className={`tag ${row.live ? "tag-live" : "tag-syn"}`}>{row.live ? "live" : "bp"}</span>
</div>
<div className="rh-row-scenario">{row.scenarioLabel}</div>
<div className="rh-row-step">{row.run.activeStep ?? "—"}</div>
<div className="rh-row-bar">
<div
className="rh-bar-fill"
style={{
width: `${Math.max(2, (row.run.durationSec / maxDuration) * 100)}%`,
background: STATUS_COLOR[row.run.status] ?? "var(--border-strong)",
}}
title={`${Math.round(row.run.durationSec / 60)}m`}
/>
</div>
<div className="rh-row-meta">
<span className="mono"><Clock size={11} /> {Math.round(row.run.durationSec / 60)}m</span>
<span className={`pill pill-${row.run.status === "completed" || row.run.status === "done" ? "done" : row.run.status === "running" ? "running" : row.run.status === "errored" || row.run.status === "failed" ? "errored" : "queued"}`}>{row.run.status}</span>
</div>
</div>
))}
</div>
</div>
);
}

185
src/state/store.ts Normal file
View File

@ -0,0 +1,185 @@
// Global UI state for Mission Control.
import { create } from "zustand";
import { liveScenarios as snapshotLive } from "../data/live";
import { syntheticScenarios } from "../data/synthetic";
import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api } from "../lib/api";
import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio";
export type DataMode = "snapshot" | "live";
interface TourState {
active: boolean;
index: number;
autoplay: boolean;
}
export interface Toast {
id: number;
kind: "info" | "ok" | "warn" | "err";
msg: string;
}
interface AppState {
scene: SceneId;
setScene: (s: SceneId) => void;
/** snapshot = bundled scenarios.json; live = in-browser API client */
mode: DataMode;
setMode: (m: DataMode) => Promise<void>;
liveLoading: boolean;
liveError: string | null;
liveTotals: { workItems: number; distinctDefs: number } | null;
liveFetchedAt: number | null;
scenarios: ProcessScenario[];
scenarioId: string;
setScenarioId: (id: string) => void;
selectedStepId: string | null;
setSelectedStepId: (id: string | null) => void;
cmdOpen: boolean;
setCmdOpen: (v: boolean) => void;
tour: TourState;
startTour: () => void;
endTour: () => void;
tourPrev: () => void;
tourNext: () => void;
tourSetIndex: (i: number) => void;
setTourAutoplay: (v: boolean) => void;
recents: string[];
pushRecent: (label: string) => void;
inspectorTab: "overview" | "rules" | "evidence" | "raw" | "runs";
setInspectorTab: (t: AppState["inspectorTab"]) => void;
toasts: Toast[];
pushToast: (kind: Toast["kind"], msg: string) => void;
dismissToast: (id: number) => void;
}
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios];
const initialScenario = SNAPSHOT_SCENARIOS[0];
let toastSeq = 0;
export const useApp = create<AppState>((set, get) => ({
scene: "landing",
setScene: (scene) => set({ scene }),
mode: "snapshot",
setMode: async (mode) => {
if (mode === get().mode) return;
if (mode === "snapshot") {
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
return;
}
set({ mode: "live", liveLoading: true, liveError: null });
try {
const ping = await api.ping();
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios];
const first = merged[0];
set({
scenarios: merged,
liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(),
liveLoading: false,
scenarioId: first?.id ?? get().scenarioId,
selectedStepId: first?.defaultStepId ?? null,
});
get().pushToast("ok", `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`);
} catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
}
},
liveLoading: false,
liveError: null,
liveTotals: null,
liveFetchedAt: null,
scenarios: SNAPSHOT_SCENARIOS,
scenarioId: initialScenario?.id ?? "",
setScenarioId: (id) => {
const sc = get().scenarios.find((s) => s.id === id);
set({
scenarioId: id,
selectedStepId: sc?.defaultStepId ?? null,
});
if (sc) get().pushRecent(`Scenario: ${sc.family.label}`);
},
selectedStepId: initialScenario?.defaultStepId ?? null,
setSelectedStepId: (id) => set({ selectedStepId: id }),
cmdOpen: false,
setCmdOpen: (cmdOpen) => set({ cmdOpen }),
tour: { active: false, index: 0, autoplay: false },
startTour: () => {
const sc = get().scenarios.find((s) => s.id === get().scenarioId);
const first = sc?.tour[0];
set({
tour: { active: true, index: 0, autoplay: false },
scene: "mission",
selectedStepId: first?.selectStep ?? get().selectedStepId,
});
},
endTour: () => set({ tour: { active: false, index: 0, autoplay: false } }),
tourPrev: () =>
set((s) => {
const sc = s.scenarios.find((sc) => sc.id === s.scenarioId);
const idx = Math.max(0, s.tour.index - 1);
const step = sc?.tour[idx];
return {
tour: { ...s.tour, index: idx },
selectedStepId: step?.selectStep ?? s.selectedStepId,
};
}),
tourNext: () =>
set((s) => {
const sc = s.scenarios.find((sc) => sc.id === s.scenarioId);
if (!sc) return s;
const last = sc.tour.length - 1;
const idx = Math.min(last, s.tour.index + 1);
const step = sc.tour[idx];
const nextScenarioId = step?.switchToScenario ?? s.scenarioId;
return {
tour: { ...s.tour, index: idx },
scenarioId: nextScenarioId,
selectedStepId: step?.selectStep ?? s.selectedStepId,
};
}),
tourSetIndex: (i) => set((s) => ({ tour: { ...s.tour, index: i } })),
setTourAutoplay: (v) => set((s) => ({ tour: { ...s.tour, autoplay: v } })),
recents: [],
pushRecent: (label) =>
set((s) => ({ recents: [label, ...s.recents.filter((r) => r !== label)].slice(0, 8) })),
inspectorTab: "overview",
setInspectorTab: (inspectorTab) => set({ inspectorTab }),
toasts: [],
pushToast: (kind, msg) => {
const id = ++toastSeq;
set((s) => ({ toasts: [...s.toasts, { id, kind, msg }] }));
setTimeout(() => get().dismissToast(id), 4200);
},
dismissToast: (id) =>
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}));
export const scenarioById = (id: string): ProcessScenario | undefined =>
useApp.getState().scenarios.find((s) => s.id === id);

26
tsconfig.app.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Proxy /api/* to the demo.flow-master.ai backend during dev so live-mode
// fetches don't hit CORS. In production, the build is deployed under the
// same origin as the backend, so no proxy is needed.
const TARGET = process.env.VITE_FM_BASE || 'https://demo.flow-master.ai'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: TARGET,
changeOrigin: true,
secure: true,
},
},
},
})