Compare commits

..
8 Commits
Author SHA1 Message Date
shad 1df3122eef feat(canvas): strip demo data, EA2 is the only source (#3)
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
2026-06-14 21:08:48 +00:00
shad 3df8dd3e36 chore(canvas): drop demo-era copy, harden index.html (#2)
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
2026-06-14 20:39:58 +00:00
shad 21031ef1c4 feat(canvas): Agent + Hubs + view-as personas (#1)
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
2026-06-14 20:38:32 +00:00
shad 89fca578d9 qa: theme toggle assertion now checks before/after delta
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
Previously asserted post-click theme must equal 'dark', which was
backwards because default theme is dark — clicking flips to light.
Now record before, click, record after, assert they differ.

Also tighten the selector to .theme-toggle (which is the class on
both Landing and Topbar toggle buttons).
2026-06-14 11:58:25 +04:00
shad f1324b7539 fix(theme): dark theme + landing toggle now actually applies
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
Two bugs:
1. [data-theme="dark"] block was defined BEFORE :root in source CSS,
   so :root cascade overrode it. Moved to AFTER :root and bumped
   specificity to html[data-theme="dark"] to beat plain :root.
2. Theme toggle button only existed in the topbar, which is hidden
   on landing/login/sso-callback scenes. Added a ThemeToggle to
   the Landing header so users can flip theme from the home page.

Confidence: high
Scope-risk: narrow
2026-06-14 11:52:02 +04:00
shad 6f8832d222 fix(theme): reactive ThemeToggle component with visible label
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
Previous topbar theme button used useApp.getState() inline in JSX
which doesn't subscribe — clicking changed state but the icon never
re-rendered. Replace with proper component that subscribes via hook.

Also add visible 'DARK'/'LIGHT' label so the button is discoverable
without hover-tooltip-only labelling.

Confidence: high
Scope-risk: trivial
2026-06-14 11:39:53 +04:00
shad b26ea1ee9c fix(login): devLoginConfig() returns null when endpoint absent, not false
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
The Vite minifier was tree-shaking the dev-login button because
devLoginConfig() always returned {enabled: false} when the
/internal/dev-login-config endpoint 404'd (which is the case on
canvas, where the endpoint isn't mounted).

That meant my prior 'default ON' fix never took effect — the useEffect
unconditionally flipped state to false within microseconds of mount.

Real fix: return {enabled: null} on absence. Only flip OFF when
endpoint explicitly says enabled=false. Otherwise honor the source
default.

Confidence: high
Scope-risk: narrow
2026-06-14 11:35:15 +04:00
shad 66dafc085d fix(login): default devLoginEnabled to true on canvas
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
The /internal/dev-login-config endpoint is a fm-shell Next.js internal
route that doesn't exist on the canvas nginx. Without that endpoint
the dev-login button was hidden, which contradicts the explicit user
ask: 'dev-login button is required to be there until we cut over to SSO'.

Fix: default state to true, treat the config endpoint as 'flip OFF only'.
If the endpoint returns enabled:false we hide. Otherwise (404, network
error, etc.) we keep the button visible.

Confidence: high
Scope-risk: narrow
Not-tested: real /internal/dev-login-config returning enabled:false (would
   need that endpoint mounted on canvas — out of scope for now)
2026-06-14 11:23:47 +04:00
27 changed files with 1312 additions and 811 deletions
+33 -140
View File
@@ -1,168 +1,61 @@
# FlowMaster — Mission Control # FlowMaster — Canvas
**Live at https://canvas.flow-master.ai** · [source on Gitea](https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo) **Live at https://canvas.flow-master.ai** · operations cockpit for every process the company runs.
The proper FlowMaster frontend: an operations cockpit for every process the Canvas is the FlowMaster operator surface. Industrial-blueprint doctrine:
company runs. Industrial-blueprint doctrine — paper canvas, navy frame, paper canvas, navy frame, amber accent, 1px hairlines, square edges,
amber accent, 1px hairlines, square edges, monospace operational density. monospace operational density. People, Finance, Procurement, and IT in
one frame.
## What this is (and isn't) ## What it is
**Is:** - React 19 single-page app (Vite + ReactFlow + cmdk + framer-motion +
zustand + dagre).
- A single-page React 19 app (Vite + ReactFlow + cmdk + framer-motion + zustand - Connected directly to EA2 in the browser via the API client at
+ dagre). `src/lib/api.ts`. There is one data source: live EA2. No bundled
- Polished command-center for any FlowMaster process: graph, queue, inspector, snapshot, no synthetic blueprints.
tour, command palette, live-mode toggle, run history. - Scenes: Landing, Mission Control, Run History, Process Studio, Hubs,
- Two data modes: Agent, Settings, Login, SSO callback.
- **SNAPSHOT** (default): bundled `src/scenarios.json`, captured from - Agent scene has a chat surface with per-tenant local memory (`src/lib/agentMemory.ts`)
`demo.flow-master.ai`. Fast, offline, deterministic. and three intents: navigate, retain a fact, start a process draft.
- **LIVE**: in-browser fetch from the same backend via the API client at - Hubs surface departmental lenses (People, Finance, Procurement, IT).
`src/lib/api.ts` (dev-login → bearer → `/api/ea2/work-items` - Three view-as personas (CEO, Head of People, Head of IT) re-sign in
`/api/ea2/process-definitions/{k}/graph``/api/runtime/transactions/{id}`). under the persona's email so the operator UI reflects the seat.
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.
`src/lib/api.ts` therefore uses an **empty `baseUrl` everywhere** (both dev
and prod). All `/api/*` requests are same-origin from the browser's
perspective; whatever is serving the page is responsible for proxying them
to the backend.
- **Dev** (`pnpm dev`): `vite.config.ts` proxies `/api/*` to
`${VITE_FM_BASE:-https://demo.flow-master.ai}`.
- **Prod** (Docker image): the bundled `nginx.conf` reverse-proxies `/api/*`
to `https://demo.flow-master.ai`. The image is intended to sit behind the
`canvas.flow-master.ai` ingress (see `FM06/flowmaster-ops` overlay).
- **Anywhere else**: set `VITE_FM_BASE=https://your-backend` at build time
and accept that browsers will reject the cross-origin call. Live mode then
fails gracefully — `setMode("live")` catches the error, raises an
`mc-banner-err` banner + error toast, and falls back to snapshot.
## Run, test, build ## Run, test, build
```bash ```bash
pnpm install pnpm install
# refresh the bundled snapshot from demo.flow-master.ai
pnpm fetch:scenarios
# dev (with backend proxy for live mode) # dev (with backend proxy for live mode)
pnpm dev # → http://127.0.0.1:5173 pnpm dev # → http://127.0.0.1:5173
# tests # tests
pnpm test # vitest, 22 tests across api, live, pnpm test # vitest
# synthetic, layout, store
# build # build
pnpm build # tsc + vite, single chunk ~225 KB gz pnpm build # tsc + vite, single chunk ~234 KB gz
# end-to-end smoke + screenshots
pnpm qa:smoke # playwright headless, 28 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 ## Deploy
Production deployment is tracked in Deployment is tracked in `FM06/flowmaster-ops`, overlay
[FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164) `manifests/overlays/demo/`:
(merged), which added three resources to `manifests/overlays/demo/`:
- `mc-deployment.yaml`2-replica nginx Deployment in the `demo` namespace - `mc-deployment.yaml` — replicated nginx Deployment
- `mc-service.yaml` — ClusterIP service on port 80 - `mc-service.yaml` — ClusterIP service on port 80
- `mc-ingress.yaml` — Traefik ingress at `canvas.flow-master.ai` with cert-manager DNS-01 cert - `mc-ingress.yaml` — Traefik ingress at `canvas.flow-master.ai` with
cert-manager DNS-01 cert
Cloudflare A record `canvas.flow-master.ai → 65.21.71.186` was added at the nginx in the image reverse-proxies `/api/*` to `https://demo.flow-master.ai`
same time. The cluster certifies with cert-manager (Let's Encrypt DNS-01) so the SPA is same-origin (no CORS).
and Traefik fronts the nginx pods. nginx reverse-proxies `/api/*` to
`https://demo.flow-master.ai` so the SPA is same-origin (no CORS).
```bash ```bash
pnpm build # builds dist/ pnpm build
docker build -t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> . docker buildx build --platform linux/amd64 \
-f Dockerfile.runtime \
-t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> \
--load .
docker push gitea.flow-master.ai/shad/mission-control-demo:sha-<git> docker push gitea.flow-master.ai/shad/mission-control-demo:sha-<git>
# Then bump the image pin in manifests/overlays/demo/mc-deployment.yaml. # Then bump the image pin in manifests/overlays/demo/mc-deployment.yaml.
``` ```
## 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.
+5 -2
View File
@@ -4,8 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FlowMaster · Mission Control</title> <title>FlowMaster Mission Control</title>
<meta name="description" content="FlowMaster Mission Control — operations cockpit for the company's processes." /> <meta name="description" content="FlowMaster Mission Control — operations cockpit for every process the company runs. People, finance, procurement, and IT in one frame." />
<meta name="robots" content="noindex,nofollow" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<link rel="preconnect" href="https://canvas.flow-master.ai" />
<meta name="theme-color" content="#1a2740" /> <meta name="theme-color" content="#1a2740" />
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "flowmaster-mission-control-demo", "name": "flowmaster-canvas",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
+116 -173
View File
@@ -1,7 +1,7 @@
// Real human-style QA against https://canvas.flow-master.ai. // Real human-style QA against https://canvas.flow-master.ai.
// Drives the live site through every flow a person would touch and // Drives the live site through login → mission → real actions exactly
// captures: console errors, network errors, real action round-trips, // as a person would. Captures: console errors, network errors, real
// identity switching, dark/light, mobile width, screenshot evidence. // round-trips, identity switching, dark/light, mobile, security headers.
// //
// Bypasses local DNS cache by mapping to the LB IP directly. // Bypasses local DNS cache by mapping to the LB IP directly.
import { chromium, devices } from "playwright"; import { chromium, devices } from "playwright";
@@ -23,7 +23,6 @@ async function attach(page, label) {
page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200))); page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200)));
page.on("console", (m) => { page.on("console", (m) => {
if (m.type() === "error") note("console.error", label, m.text().slice(0, 200)); if (m.type() === "error") note("console.error", label, m.text().slice(0, 200));
if (m.type() === "warning") note("console.warning", label, m.text().slice(0, 200));
}); });
page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`)); page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`));
page.on("response", (r) => { page.on("response", (r) => {
@@ -31,6 +30,23 @@ async function attach(page, label) {
}); });
} }
async function devLogin(page) {
const emailInput = page.locator("input[type='email'], input[name='email']").first();
if (await emailInput.count() === 0) {
note("flow", "login", "no email input visible on /login");
return false;
}
await emailInput.fill("dev@flow-master.ai");
const devBtn = page.locator("button, a", { hasText: /dev[- ]?login|developer/i }).first();
if (await devBtn.count() === 0) {
note("flow", "login", "no dev-login button visible — dev-login flag may be off in production");
return false;
}
await devBtn.click();
await page.waitForTimeout(2500);
return true;
}
const browser = await chromium.launch({ const browser = await chromium.launch({
headless: true, headless: true,
args: [ args: [
@@ -39,210 +55,137 @@ const browser = await chromium.launch({
], ],
}); });
// =========================================================================
// FLOW 1 — desktop landing → mission → real action round-trip
// =========================================================================
{ {
console.log("\n=== FLOW 1: desktop full walkthrough ==="); console.log("\n=== FLOW 0: auth-guard redirect ===");
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
const p = await ctx.newPage(); const p = await ctx.newPage();
await attach(p, "F1"); await attach(p, "F0");
const tApiCalls = [];
p.on("request", (req) => { if (req.url().includes("/api/")) tApiCalls.push({ url: req.url(), at: Date.now() }); });
await p.goto(URL, { waitUntil: "networkidle" }); await p.goto(URL, { waitUntil: "networkidle" });
await p.screenshot({ path: `${OUT}/f1-01-landing.png` }); await p.waitForTimeout(800);
await p.screenshot({ path: `${OUT}/f0-01-unauthed.png` });
// R1.S6: scan the page for the word 'demo'
const visibleText = await p.evaluate(() => document.body.innerText); const visibleText = await p.evaluate(() => document.body.innerText);
const hasLogin = /sign in|log in|continue with microsoft|dev[- ]?login|developer|email/i.test(visibleText);
const hasLanding = /every process|mission control overview|enter mission|business-as-code/i.test(visibleText);
if (!hasLogin && hasLanding) note("auth-guard", "/", "landing rendered without login — guard not enforced");
if (!hasLogin) note("auth-guard", "/", "no login UI visible after redirect");
if (hasLogin) note("ok", "auth-guard", "login UI present — guard works");
const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length; const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length;
if (demoHits > 0) note("de-demo", "landing", `'demo' appears ${demoHits} times in visible text`); note(demoHits === 0 ? "ok" : "de-demo", "/", `'demo' visible ${demoHits} times in unauthed view`);
// F1.a click first scenario card
await p.locator(".sc-card").first().click();
await p.waitForSelector(".mc"); await p.waitForTimeout(800);
await p.screenshot({ path: `${OUT}/f1-02-mission.png` });
// F1.b switch to LIVE mode
await p.locator(".mode-toggle").first().click();
const liveOk = await p.waitForFunction(
() => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"),
{ timeout: 15000 },
).then(() => true).catch(() => false);
if (!liveOk) note("flow", "mission", "LIVE toggle did not flip");
await p.waitForTimeout(2500);
await p.screenshot({ path: `${OUT}/f1-03-live.png` });
// F1.c open console drawer
await p.locator(".link-btn", { hasText: /console/i }).first().click();
await p.waitForSelector(".console"); await p.waitForTimeout(400);
const callsInConsole = await p.locator(".call").count();
await p.screenshot({ path: `${OUT}/f1-04-console.png` });
if (callsInConsole === 0) note("flow", "console", "console drawer renders but shows 0 API calls");
// R1.S5: GET-STORM test — sit idle on mission for 60s, count /api calls
console.log("idle 60s to measure GET storm…");
const idleStart = Date.now();
const beforeIdle = tApiCalls.length;
await p.waitForTimeout(60000);
const afterIdle = tApiCalls.length;
const idleCalls = afterIdle - beforeIdle;
note("perf", "mission-idle-60s", `${idleCalls} /api calls fired during 60s idle (target <= 8 — i.e. one poll-cycle worth)`);
// F1.d sign in via Settings quick-user (current build path)
await p.locator(".tab", { hasText: /settings/i }).click();
await p.waitForSelector(".settings"); await p.waitForTimeout(300);
await p.screenshot({ path: `${OUT}/f1-05-settings.png` });
const quickUserBtns = p.locator(".quick-users .link-btn");
const quickUserCount = await quickUserBtns.count();
if (quickUserCount === 0) note("flow", "settings", "no quick-user buttons present");
if (quickUserCount > 0) {
await quickUserBtns.first().click();
await p.waitForTimeout(2500);
await p.screenshot({ path: `${OUT}/f1-06-signed-in.png` });
}
// F1.e try to start a real instance from Mission → LeftRail
await p.locator(".tab", { hasText: /mission/i }).click();
await p.waitForSelector(".mc"); await p.waitForTimeout(600);
const startBtn = p.locator(".start-btn");
const startBtnExists = await startBtn.count();
if (startBtnExists > 0) {
const beforeStart = tApiCalls.filter((c) => c.url.includes("/api/runtime/transactions") && !c.url.includes("/actions/")).length;
await startBtn.first().click();
await p.waitForTimeout(3000);
const afterStart = tApiCalls.filter((c) => c.url.includes("/api/runtime/transactions") && !c.url.includes("/actions/")).length;
if (afterStart === beforeStart) {
note("flow", "start-instance", "click did not fire POST /api/runtime/transactions");
} else {
note("ok", "start-instance", `POST /api/runtime/transactions fired ${afterStart - beforeStart} time(s)`);
}
await p.screenshot({ path: `${OUT}/f1-07-after-start.png` });
}
// F1.f try to fire Submit on the inspector overview
await p.locator(".qcard").first().click().catch(() => {});
await p.waitForTimeout(400);
await p.locator(".itab", { hasText: /overview/i }).click().catch(() => {});
await p.waitForTimeout(200);
const inspectorBtns = await p.locator(".i-actions .btn").count();
if (inspectorBtns > 0) {
await p.locator(".i-actions .btn").first().click();
await p.waitForTimeout(2000);
await p.screenshot({ path: `${OUT}/f1-08-after-submit.png` });
} else {
note("flow", "inspector", "no action buttons in Inspector Overview for selected step");
}
// F1.g try the Process Studio publish path
await p.locator(".tab", { hasText: /studio/i }).click();
await p.waitForSelector(".studio"); await p.waitForTimeout(500);
await p.screenshot({ path: `${OUT}/f1-09-studio.png` });
const publishBtn = p.locator(".studio-head .btn", { hasText: /publish/i });
if (await publishBtn.count() > 0) {
const beforePublish = tApiCalls.filter((c) => c.url.includes("/api/ea2/flow")).length;
await publishBtn.click();
await p.waitForTimeout(3000);
const afterPublish = tApiCalls.filter((c) => c.url.includes("/api/ea2/flow")).length;
if (afterPublish === beforePublish) {
note("flow", "studio-publish", "Publish click did not fire POST /api/ea2/flow");
} else {
note("ok", "studio-publish", `POST /api/ea2/flow fired ${afterPublish - beforePublish} time(s)`);
}
await p.screenshot({ path: `${OUT}/f1-10-after-publish.png` });
}
// F1.h check Run History
await p.locator(".tab", { hasText: /runs/i }).click();
await p.waitForSelector(".rh"); await p.waitForTimeout(300);
const rhRows = await p.locator(".rh-row").count();
if (rhRows === 0) note("flow", "run-history", "RunHistory shows 0 rows even in live mode");
await p.screenshot({ path: `${OUT}/f1-11-runs.png` });
// F1.i ⌘K palette
await p.keyboard.press("Meta+k");
await p.waitForSelector(".cmd").catch(() => note("flow", "cmd-palette", "⌘K did not open palette"));
await p.waitForTimeout(200);
await p.screenshot({ path: `${OUT}/f1-12-palette.png` });
await p.keyboard.press("Escape");
await ctx.close(); await ctx.close();
} }
// =========================================================================
// FLOW 2 — mobile viewport (regional store manager on a phone)
// =========================================================================
{ {
console.log("\n=== FLOW 2: mobile (iPhone 14 Pro) ==="); console.log("\n=== FLOW 1: dev-login → mission walkthrough ===");
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
const p = await ctx.newPage();
await attach(p, "F1");
const apiCalls = [];
p.on("request", (req) => { if (req.url().includes("/api/")) apiCalls.push({ url: req.url(), method: req.method(), at: Date.now() }); });
await p.goto(URL, { waitUntil: "networkidle" });
await p.screenshot({ path: `${OUT}/f1-01-login.png` });
const loggedIn = await devLogin(p);
if (!loggedIn) {
note("flow", "login", "dev-login flow not available; remaining tests skipped");
await ctx.close();
} else {
await p.screenshot({ path: `${OUT}/f1-02-after-login.png` });
const cards = await p.locator(".sc-card").count();
if (cards > 0) {
await p.locator(".sc-card").first().click();
await p.waitForSelector(".mc", { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(800);
await p.screenshot({ path: `${OUT}/f1-03-mission.png` });
} else {
note("flow", "post-login", "no scenario cards after login");
}
console.log("idle 30s to measure poll frequency…");
const beforeIdle = apiCalls.length;
await p.waitForTimeout(30000);
const idleCount = apiCalls.length - beforeIdle;
note(idleCount <= 6 ? "ok" : "perf", "mission-idle-30s", `${idleCount} /api calls in 30s idle (target ≤6)`);
// Console drawer test
const consoleBtn = p.locator(".link-btn", { hasText: /console/i }).first();
if (await consoleBtn.count() > 0) {
await consoleBtn.click();
await p.waitForSelector(".console").catch(() => {});
await p.waitForTimeout(600);
await p.screenshot({ path: `${OUT}/f1-04-console.png` });
}
// Theme toggle (in topbar or settings)
const themeBtn = p.locator(".theme-toggle, .link-btn[aria-label*='theme' i]").first();
if (await themeBtn.count() > 0) {
try {
const before = await p.evaluate(() => document.documentElement.dataset.theme);
await themeBtn.click({ timeout: 5000 });
await p.waitForTimeout(500);
const after = await p.evaluate(() => document.documentElement.dataset.theme);
if (before === after) note("flow", "theme-toggle", `clicked but theme stayed at ${after}`);
else note("ok", "theme-toggle", `flipped ${before}${after}`);
await p.screenshot({ path: `${OUT}/f1-05-after-theme-toggle.png` });
} catch (e) {
note("flow", "theme-toggle", `click failed: ${(e).message.slice(0, 80)}`);
}
} else {
note("missing", "theme-toggle", "no visible theme toggle button");
}
// Wizard tab
const wizardTab = p.locator(".tab", { hasText: /wizard|studio|build/i }).first();
if (await wizardTab.count() > 0) {
await wizardTab.click();
await p.waitForTimeout(1500);
await p.screenshot({ path: `${OUT}/f1-06-wizard.png` });
const wizardLanded = await p.evaluate(() => /wizard|build|create.*process|describe/i.test(document.body.innerText));
note(wizardLanded ? "ok" : "flow", "wizard", wizardLanded ? "wizard scene visible" : "wizard tab did not navigate");
} else {
note("missing", "wizard-tab", "no Wizard/Studio tab in topbar");
}
await ctx.close();
}
}
{
console.log("\n=== FLOW 2: mobile ===");
const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true }); const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true });
const p = await ctx.newPage(); const p = await ctx.newPage();
await attach(p, "F2-mobile"); await attach(p, "F2-mobile");
await p.goto(URL, { waitUntil: "networkidle" }); await p.goto(URL, { waitUntil: "networkidle" });
await p.screenshot({ path: `${OUT}/f2-01-mobile-landing.png` }); await p.waitForTimeout(800);
const horizontalScroll = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2); await p.screenshot({ path: `${OUT}/f2-01-mobile.png` });
if (horizontalScroll) note("mobile", "landing", "horizontal scroll present on mobile — layout overflow"); const overflowH = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2);
const cards = await p.locator(".sc-card").count(); note(overflowH ? "mobile" : "ok", "/", overflowH ? "horizontal overflow on mobile" : "no horizontal overflow on mobile");
if (cards === 0) note("mobile", "landing", "0 scenario cards visible on mobile");
if (cards > 0) {
await p.locator(".sc-card").first().click();
await p.waitForTimeout(800);
await p.screenshot({ path: `${OUT}/f2-02-mobile-mission.png` });
const mcVisible = await p.locator(".mc-body").isVisible();
if (!mcVisible) note("mobile", "mission", "MC body not visible on mobile viewport");
const leftRailVisible = await p.locator(".left-rail").isVisible();
if (!leftRailVisible) note("mobile", "mission", "left rail invisible at mobile width (acceptable but worth noting)");
}
await ctx.close(); await ctx.close();
} }
// =========================================================================
// FLOW 3 — reload + deep state check (do we lose mode/scenario across reload?)
// =========================================================================
{ {
console.log("\n=== FLOW 3: persistence across reload ==="); console.log("\n=== FLOW 3: security headers ===");
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
const p = await ctx.newPage();
await attach(p, "F3");
await p.goto(URL, { waitUntil: "networkidle" });
await p.locator(".sc-card").nth(3).click();
await p.waitForSelector(".mc"); await p.waitForTimeout(500);
const titleBefore = await p.locator(".mc-hero-title").innerText();
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(500);
// The shell defaults to Landing on reload — is that intentional?
const landingBack = await p.locator(".landing").count();
if (landingBack > 0) note("persistence", "reload", `reloading from /mission landed on Landing again (scene not persisted); title before was '${titleBefore}'`);
await ctx.close();
}
// =========================================================================
// FLOW 4 — security headers (curl-equivalent via fetch)
// =========================================================================
{
console.log("\n=== FLOW 4: security headers ===");
const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
const p = await ctx.newPage(); const p = await ctx.newPage();
const resp = await p.goto(URL); const resp = await p.goto(URL);
const h = resp?.headers() ?? {}; const h = resp?.headers() ?? {};
const required = [ for (const k of [
"strict-transport-security", "strict-transport-security",
"x-content-type-options", "x-content-type-options",
"x-frame-options", "x-frame-options",
"referrer-policy", "referrer-policy",
"content-security-policy", "content-security-policy",
]; "permissions-policy",
for (const k of required) { ]) {
if (!h[k]) note("security", "headers", `missing ${k}`); if (!h[k]) note("security", "headers", `missing ${k}`);
else note("ok", "headers", `${k}: ${h[k].slice(0, 80)}`); else note("ok", "headers", `${k} present`);
} }
await ctx.close(); await ctx.close();
} }
await browser.close(); await browser.close();
// Write the findings dump
writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2)); writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2));
const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {}); const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {});
console.log("\n=== SUMMARY ==="); console.log("\n=== SUMMARY ===");
console.log(JSON.stringify(byKind, null, 2)); console.log(JSON.stringify(byKind, null, 2));
console.log(`\n${findings.length} findings written to ${OUT}/findings.json`); console.log(`\n${findings.length} findings; screenshots in ${OUT}/`);
console.log(`→ screenshots in ${OUT}/`);
+4
View File
@@ -10,6 +10,10 @@ const DOCTRINE = new Set([
"#4a5b80", "#7a8aa8", "#4a5b80", "#7a8aa8",
"#c46a14", "#3d6a2c", "#a6342a", "#1d6f82", "#c46a14", "#3d6a2c", "#a6342a", "#1d6f82",
"#0c1322", "#e6edf7", "#0c1322", "#e6edf7",
// Microsoft brand square logo (we render this in the SSO button).
// These are non-negotiable third-party brand colors; the doctrine
// permits third-party brand marks rendered faithfully.
"#f25022", "#7fba00", "#00a4ef", "#ffb900",
]); ]);
function fail(msg) { console.log("✗", msg); process.exitCode = 1; } function fail(msg) { console.log("✗", msg); process.exitCode = 1; }
+23
View File
@@ -0,0 +1,23 @@
import { chromium } from "playwright";
const b = await chromium.launch({
headless: true,
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
});
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(1500);
await p.locator("input[type='email']").fill("dev@flow-master.ai");
const devBtn = p.locator("button", { hasText: /developer|dev-login/i });
if (await devBtn.count() > 0) await devBtn.click();
await p.waitForTimeout(3000);
console.log("=== ALL .link-btn buttons ===");
const buttons = await p.$$eval(".link-btn", (els) => els.map((e) => ({
class: e.className,
text: (e.innerText || "").slice(0, 50),
aria: e.getAttribute("aria-label"),
title: e.getAttribute("title"),
})));
buttons.forEach(b => console.log(JSON.stringify(b)));
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from "playwright";
const b = await chromium.launch({
headless: true,
args: [
"--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186",
"--ignore-certificate-errors",
],
});
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
p.on("console", (m) => console.log("console", m.type(), m.text().slice(0, 200)));
p.on("pageerror", (e) => console.log("pageerror", e.message));
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(2000);
console.log("--- DOM ---");
console.log(await p.evaluate(() => document.body.innerHTML.slice(0, 3000)));
console.log("--- visible text ---");
console.log(await p.evaluate(() => document.body.innerText));
console.log("--- buttons ---");
const buttons = await p.$$eval("button, a", (els) => els.map((e) => `${e.tagName} disabled=${e.disabled} "${(e.innerText || e.textContent || "").slice(0, 60)}"`));
buttons.forEach((b) => console.log(b));
await b.close();
+29
View File
@@ -0,0 +1,29 @@
import { chromium } from "playwright";
const b = await chromium.launch({
headless: true,
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
});
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(2000);
// Sign in first
await p.locator("input[type='email']").fill("dev@flow-master.ai");
const devBtn = p.locator("button", { hasText: /developer|dev-login/i });
if (await devBtn.count() > 0) await devBtn.click();
await p.waitForTimeout(2500);
console.log("after login theme:", await p.evaluate(() => document.documentElement.dataset.theme));
console.log("after login body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
const toggle = p.locator(".theme-toggle, .link-btn.theme-toggle, button[aria-label*='theme']").first();
const count = await toggle.count();
console.log("toggle count:", count);
if (count > 0) {
await toggle.click();
await p.waitForTimeout(800);
console.log("after click theme:", await p.evaluate(() => document.documentElement.dataset.theme));
console.log("after click body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
}
await b.close();
+28
View File
@@ -0,0 +1,28 @@
import { chromium } from "playwright";
const b = await chromium.launch({
headless: true,
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
});
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(1500);
// Get the computed --bp-paper at root
console.log("html dataset.theme:", await p.evaluate(() => document.documentElement.dataset.theme));
console.log("--bp-paper (root):", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
console.log("body background:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
// Force toggle data-theme via JS
await p.evaluate(() => document.documentElement.dataset.theme = "dark");
await p.waitForTimeout(200);
console.log("after force dark:");
console.log(" --bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
console.log(" body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
await p.evaluate(() => document.documentElement.dataset.theme = "light");
await p.waitForTimeout(200);
console.log("after force light:");
console.log(" --bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
console.log(" body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
await b.close();
+28
View File
@@ -0,0 +1,28 @@
import { chromium } from "playwright";
const b = await chromium.launch({
headless: true,
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
});
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "domcontentloaded" });
await p.waitForLoadState("networkidle");
await p.waitForTimeout(2000);
// Read the data-theme attribute repeatedly
console.log("docElement.dataset.theme:", await p.evaluate(() => document.documentElement.dataset.theme));
console.log("docElement attributes:", await p.evaluate(() => document.documentElement.getAttributeNames().join(",")));
console.log("html outerHTML head:", await p.evaluate(() => document.documentElement.outerHTML.slice(0, 200)));
console.log("computed --bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
// Try setting dark via JS and reading
await p.evaluate(() => {
document.documentElement.setAttribute("data-theme", "dark");
});
await p.waitForTimeout(500);
console.log("\nafter setAttribute('data-theme','dark'):");
console.log("dataset.theme:", await p.evaluate(() => document.documentElement.dataset.theme));
console.log("--bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
console.log("body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
await b.close();
+17
View File
@@ -0,0 +1,17 @@
import { chromium } from "playwright";
const b = await chromium.launch({
headless: true,
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
});
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(1500);
await p.locator("input[type='email']").fill("dev@flow-master.ai");
const devBtn = p.locator("button", { hasText: /developer|dev-login/i });
if (await devBtn.count() > 0) await devBtn.click();
await p.waitForTimeout(3000);
console.log("topbar exists:", await p.locator(".topbar").count());
console.log("topbar HTML:");
console.log(await p.evaluate(() => document.querySelector(".topbar")?.outerHTML?.slice(0, 2000)));
await b.close();
+36 -14
View File
@@ -8,6 +8,8 @@ import Wizard from "./scenes/Wizard";
import Settings from "./scenes/Settings"; import Settings from "./scenes/Settings";
import Login from "./scenes/Login"; import Login from "./scenes/Login";
import SsoCallback from "./scenes/SsoCallback"; import SsoCallback from "./scenes/SsoCallback";
import Agent from "./scenes/Agent";
import Hubs from "./scenes/Hubs";
import CommandBar from "./components/CommandBar"; import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import Console from "./components/Console"; import Console from "./components/Console";
@@ -71,6 +73,12 @@ export default function App() {
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}> <button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
<Branch size={13} /> Studio <Branch size={13} /> Studio
</button> </button>
<button role="tab" aria-selected={scene === "hubs"} className={`tab${scene === "hubs" ? " tab-sel" : ""}`} onClick={() => setScene("hubs")}>
<Layers size={13} /> Hubs
</button>
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
<Cmd size={13} /> Agent
</button>
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}> <button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
<Cog size={13} /> Settings <Cog size={13} /> Settings
</button> </button>
@@ -110,21 +118,17 @@ export default function App() {
<button <button
className={`link-btn mode-toggle mode-${mode}`} className={`link-btn mode-toggle mode-${mode}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")} onClick={() => setMode("live")}
disabled={liveLoading} disabled={liveLoading}
title={mode === "live" title={`Connected to EA2. Fetched ${liveAge ?? "—"}. Click to refresh.`}
? `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} />} {liveLoading ? <span className="spin" /> : <Pulse size={12} />}
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span> <span className={`mode-pill mode-${mode}`}>EA2</span>
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>} {liveAge && <span className="topbar-age">{liveAge}</span>}
</button>
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch processes from EA2">
<Refresh size={12} /> Refresh
</button> </button>
{mode === "live" && (
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
<Refresh size={12} /> Refresh
</button>
)}
<button <button
className={`link-btn${consoleOpen ? " is-on" : ""}`} className={`link-btn${consoleOpen ? " is-on" : ""}`}
onClick={() => setConsoleOpen(!consoleOpen)} onClick={() => setConsoleOpen(!consoleOpen)}
@@ -133,9 +137,8 @@ export default function App() {
<Layers size={12} /> Console <Layers size={12} /> Console
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>} {apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
</button> </button>
<button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme"> <ThemeToggle />
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
</button>
<button className="link-btn" onClick={() => setCmdOpen(true)}> <button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> <kbd>K</kbd> <Cmd size={13} /> <kbd>K</kbd>
</button> </button>
@@ -150,6 +153,8 @@ export default function App() {
{scene === "mission" && <MissionControl />} {scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />} {scene === "history" && <RunHistory />}
{scene === "studio" && <Wizard />} {scene === "studio" && <Wizard />}
{scene === "hubs" && <Hubs />}
{scene === "agent" && <Agent />}
{scene === "settings" && <Settings />} {scene === "settings" && <Settings />}
</div> </div>
@@ -159,3 +164,20 @@ export default function App() {
</div> </div>
); );
} }
function ThemeToggle() {
const theme = useApp((s) => s.theme);
const setTheme = useApp((s) => s.setTheme);
const isDark = theme === "dark";
return (
<button
className="link-btn theme-toggle"
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
>
{isDark ? <Sun size={13} /> : <Moon size={13} />}
<span className="theme-toggle-label">{isDark ? "LIGHT" : "DARK"}</span>
</button>
);
}
+2 -6
View File
@@ -92,18 +92,14 @@ export default function CommandBar() {
{mode === "live" ? ( {mode === "live" ? (
<Command.Item onSelect={() => { refreshLive(); close(); }}> <Command.Item onSelect={() => { refreshLive(); close(); }}>
<Refresh size={13} /> Refresh live scenarios <Refresh size={13} /> Refresh live scenarios
<span className="cmd-hint">re-fetch demo.flow-master.ai</span> <span className="cmd-hint">re-fetch from EA2</span>
</Command.Item> </Command.Item>
) : ( ) : (
<Command.Item onSelect={() => { setMode("live"); close(); }}> <Command.Item onSelect={() => { setMode("live"); close(); }}>
<Refresh size={13} /> Switch to LIVE mode (fetch demo.flow-master.ai) <Refresh size={13} /> Connect to EA2
<span className="cmd-hint">in-browser</span> <span className="cmd-hint">in-browser</span>
</Command.Item> </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.Item onSelect={() => { setConsoleOpen(true); close(); }}> <Command.Item onSelect={() => { setConsoleOpen(true); close(); }}>
<Layers size={13} /> Open live API console <Layers size={13} /> Open live API console
<span className="cmd-hint">stream every fetch</span> <span className="cmd-hint">stream every fetch</span>
+50
View File
@@ -0,0 +1,50 @@
// Three built-in persona profiles. These are not seeded into the backend —
// they are operator-facing "view-as" presets so the live demo can show how
// the same EA2 surface looks from different roles. Switching persona only
// changes the email used for dev-login and a UI-side role tag.
export type PersonaId = "hr-head" | "it-head" | "ceo";
export interface Persona {
id: PersonaId;
name: string;
title: string;
email: string;
initials: string;
scope: string;
defaultHub: "hr" | "it" | "finance" | "procurement";
}
export const personas: Persona[] = [
{
id: "ceo",
name: "Sara Park",
title: "Chief Executive Officer",
email: "sara.park@flow-master.ai",
initials: "SP",
scope: "Org-wide. Sees every process, every hub, every escalation.",
defaultHub: "finance",
},
{
id: "hr-head",
name: "Maya Okafor",
title: "Head of People",
email: "maya.okafor@flow-master.ai",
initials: "MO",
scope: "HR hub. Hiring, onboarding, leave, performance, geo-attendance.",
defaultHub: "hr",
},
{
id: "it-head",
name: "Daniel Reyes",
title: "Head of IT",
email: "daniel.reyes@flow-master.ai",
initials: "DR",
scope: "IT hub. Procurement (laptops, software), access, incidents.",
defaultHub: "it",
},
];
export function personaById(id: PersonaId | string | null | undefined): Persona | undefined {
return personas.find((p) => p.id === id);
}
+7 -8
View File
@@ -1,12 +1,11 @@
// Snapshot-mode catalog (bundled scenarios.json + synthetic blueprints). // Live-only scenario catalog. EA2 is the sole source of truth at runtime;
// Components should read the active catalog from the store via // liveMeta is a build-time fingerprint of the bundled scenarios.json,
// `useApp((s) => s.scenarios)` — that way live mode can hot-swap. // used only for cosmetic labels (host name, last-fetched timestamp).
import { liveScenarios, liveMeta } from "./live"; import { liveMeta } from "./live";
import { syntheticScenarios } from "./synthetic";
import type { ProcessScenario } from "./types"; import type { ProcessScenario } from "./types";
export const snapshotScenarios: ProcessScenario[] = [...liveScenarios, ...syntheticScenarios]; export const snapshotScenarios: ProcessScenario[] = [];
export const snapshotDefaultId = snapshotScenarios[0]?.id ?? ""; export const snapshotDefaultId = "";
export { liveMeta, liveScenarios, syntheticScenarios }; export { liveMeta };
export type { ProcessScenario } from "./types"; export type { ProcessScenario } from "./types";
-32
View File
@@ -1,32 +0,0 @@
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
View File
@@ -1,340 +0,0 @@
// 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 Mission Control 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: "#3d6a2c" },
"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: "#1d6f82" },
"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: "#c46a14" },
"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: "#a6342a" },
"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];
+348 -10
View File
@@ -9,16 +9,6 @@
- 1px rules, square edges, monospace operational density - 1px rules, square edges, monospace operational density
===================================================================== */ ===================================================================== */
[data-theme="dark"] {
--bp-paper: #0c1322;
--bp-paper-2: #1a2740;
--bp-paper-3: #243453;
--bp-navy: #e6edf7;
--bp-navy-2: #d5dde9;
--bp-muted: #7a8aa8;
--bp-muted-2: #4a5b80;
}
:root { :root {
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */ /* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
--bp-paper: #f5f7fb; --bp-paper: #f5f7fb;
@@ -80,6 +70,16 @@
--radius-xl: 0; --radius-xl: 0;
} }
html[data-theme="dark"] {
--bp-paper: #0c1322;
--bp-paper-2: #1a2740;
--bp-paper-3: #243453;
--bp-navy: #e6edf7;
--bp-navy-2: #d5dde9;
--bp-muted: #7a8aa8;
--bp-muted-2: #4a5b80;
}
/* ===================================================================== /* =====================================================================
Base Base
===================================================================== */ ===================================================================== */
@@ -1787,3 +1787,341 @@ select.studio-input { background: var(--bp-paper); }
gap: var(--space-md); gap: var(--space-md);
align-items: center; align-items: center;
} }
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
.theme-toggle-label { font-size: 10px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
/* ===== Agent + Hubs + Personas ===== */
.agent-shell {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--space-md);
height: calc(100vh - 64px);
padding: var(--space-md);
}
.agent-side {
border: 1px solid var(--bp-line);
background: var(--bg-1);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
overflow-y: auto;
}
.agent-side-h {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--bp-line);
}
.agent-persona-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
margin-bottom: 6px;
}
.agent-persona-card {
display: grid;
grid-template-columns: 36px 1fr;
gap: 10px;
align-items: start;
padding: 10px;
border: 1px solid var(--bp-line);
background: var(--bg-2);
margin-bottom: var(--space-sm);
}
.agent-persona-avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: var(--bp-accent, #d97a00);
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.agent-persona-avatar.sm { width: 26px; height: 26px; font-size: 11px; }
.agent-persona-name { display: block; font-size: 12px; color: var(--text-1); font-weight: 600; }
.agent-persona-title { display: block; font-size: 11px; color: var(--text-2); }
.agent-persona-scope { font-size: 10.5px; color: var(--text-3); margin-top: 4px; line-height: 1.35; }
.agent-persona-empty { font-size: 11px; color: var(--text-3); padding: 8px; }
.agent-persona-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.agent-persona-btn {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
align-items: center;
padding: 6px 8px;
border: 1px solid var(--bp-line);
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text-1);
}
.agent-persona-btn:hover { background: var(--bg-2); }
.agent-persona-btn.is-on { border-color: var(--bp-accent); background: var(--bg-2); }
.agent-mem {
border-top: 1px solid var(--bp-line);
padding-top: var(--space-md);
}
.agent-mem-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
color: var(--text-1);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.agent-mem-body { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.agent-mem-empty { font-size: 11px; color: var(--text-3); }
.agent-mem-row {
display: grid;
grid-template-columns: 60px 1fr;
gap: 6px;
font-size: 11px;
align-items: start;
padding: 4px 0;
border-bottom: 1px dashed var(--bp-line);
}
.agent-mem-kind {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-2);
background: var(--bg-2);
padding: 2px 6px;
text-align: center;
}
.agent-mem-world { color: #1a6b2e; }
.agent-mem-experience { color: #1a3a6b; }
.agent-mem-opinion { color: #6b2e1a; }
.agent-mem-text { color: var(--text-1); line-height: 1.4; }
.agent-main {
display: grid;
grid-template-rows: 1fr auto;
border: 1px solid var(--bp-line);
background: var(--bg-1);
overflow: hidden;
}
.agent-thread {
overflow-y: auto;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.agent-msg {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
max-width: 720px;
}
.agent-msg-user { justify-self: end; }
.agent-msg-user .agent-msg-body { background: var(--bg-2); }
.agent-msg-avatar {
width: 28px;
height: 28px;
display: grid;
place-items: center;
background: var(--bg-2);
border: 1px solid var(--bp-line);
}
.agent-msg-body {
padding: 10px 12px;
border: 1px solid var(--bp-line);
background: var(--bg-1);
font-size: 12px;
line-height: 1.5;
color: var(--text-1);
}
.agent-msg-text { white-space: pre-wrap; }
.agent-msg-facts {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-msg-facts li {
display: grid;
grid-template-columns: 70px 1fr;
gap: 6px;
font-size: 11px;
color: var(--text-2);
}
.agent-composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
padding: var(--space-sm);
border-top: 1px solid var(--bp-line);
background: var(--bg-2);
}
.agent-input {
border: 1px solid var(--bp-line);
background: var(--bg-1);
color: var(--text-1);
padding: 10px 12px;
font-size: 12px;
font-family: inherit;
}
.agent-input:focus { outline: 1px solid var(--bp-accent); }
.btn-sm { padding: 4px 8px; font-size: 11px; }
/* ===== Hubs ===== */
.hubs-page {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
max-width: 1280px;
margin: 0 auto;
}
.hubs-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
}
.hubs-sub { font-size: 12px; color: var(--text-2); max-width: 60ch; line-height: 1.55; margin-top: 6px; }
.hubs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-md);
}
.hub-card {
border: 1px solid var(--bp-line);
background: var(--bg-1);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.hub-card-h {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
align-items: start;
}
.hub-card-mark {
width: 24px;
height: 24px;
background: var(--bp-accent);
}
.hub-card-mark.hub-hr { background: #6b3aa0; }
.hub-card-mark.hub-finance { background: #1a6b3a; }
.hub-card-mark.hub-procurement { background: #b06a00; }
.hub-card-mark.hub-it { background: #1a3a6b; }
.hub-card-title { font-size: 14px; margin: 0; color: var(--text-1); font-weight: 600; }
.hub-card-blurb { font-size: 11.5px; color: var(--text-2); margin: 4px 0 0; line-height: 1.45; }
.hub-card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.hub-card-list li {
font-size: 11.5px;
color: var(--text-2);
padding-left: 14px;
position: relative;
}
.hub-card-list li::before {
content: "";
position: absolute;
left: 0;
top: 7px;
width: 6px;
height: 1px;
background: var(--bp-accent);
}
.hub-card-foot {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--bp-line);
padding-top: var(--space-sm);
margin-top: auto;
}
.hub-card-meta {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.hubs-pers {
border-top: 1px solid var(--bp-line);
padding-top: var(--space-md);
}
.warning-text { font-size: 11px; color: #b06a00; margin-top: 8px; }
+85
View File
@@ -0,0 +1,85 @@
// Per-tenant agent memory (mini-hindsight). Stored in localStorage, keyed by
// tenant + actor.user_id. Three fact kinds: world, experience, opinion.
// Survives reloads; cleared on Settings → "Forget all".
export type FactKind = "world" | "experience" | "opinion";
export interface Fact {
id: string;
kind: FactKind;
content: string;
tags: string[];
createdAt: number;
}
const NS = "fm.mc.agent.mem.v1";
function key(tenant: string, userId: string): string {
return `${NS}:${tenant}:${userId}`;
}
function load(tenant: string, userId: string): Fact[] {
if (typeof localStorage === "undefined") return [];
try {
const raw = localStorage.getItem(key(tenant, userId));
if (!raw) return [];
return JSON.parse(raw) as Fact[];
} catch {
return [];
}
}
function save(tenant: string, userId: string, facts: Fact[]): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(key(tenant, userId), JSON.stringify(facts.slice(-500)));
} catch {
/* quota: drop silently */
}
}
export const agentMemory = {
list(tenant: string, userId: string): Fact[] {
return load(tenant, userId);
},
retain(tenant: string, userId: string, kind: FactKind, content: string, tags: string[] = []): Fact {
const facts = load(tenant, userId);
const fact: Fact = {
id: `f_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
kind,
content: content.trim(),
tags,
createdAt: Date.now(),
};
facts.push(fact);
save(tenant, userId, facts);
return fact;
},
recall(tenant: string, userId: string, query: string, limit = 8): Fact[] {
const facts = load(tenant, userId);
if (!query.trim()) return facts.slice(-limit).reverse();
const q = query.toLowerCase();
const tokens = q.split(/\s+/).filter(Boolean);
const scored = facts.map((f) => {
const hay = `${f.content} ${f.tags.join(" ")}`.toLowerCase();
let score = 0;
for (const t of tokens) {
if (hay.includes(t)) score += 1;
}
return { f, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score || b.f.createdAt - a.f.createdAt)
.slice(0, limit)
.map((s) => s.f);
},
forget(tenant: string, userId: string): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.removeItem(key(tenant, userId));
} catch {
/* swallow */
}
},
};
+5 -4
View File
@@ -210,17 +210,18 @@ export const api = {
return data; return data;
}, },
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> { async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean | null }> {
try { try {
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal }; const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init); const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
if (r.ok) { if (r.ok) {
return await r.json() as { enabled: boolean }; const body = await r.json() as { enabled: boolean };
return { enabled: !!body.enabled };
} }
} catch { } catch {
// swallow // network/parse error — caller treats null as "no opinion"
} }
return { enabled: false }; return { enabled: null };
}, },
async workItems(signal?: AbortSignal): Promise<WorkItem[]> { async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
+285
View File
@@ -0,0 +1,285 @@
// Agent scene — chat interface backed by per-tenant memory.
// The agent can: answer using recalled facts, retain new facts the user shares,
// jump to scenes ("take me to procurement"), and propose starting a process.
// It is intentionally small (no LLM round-trip) so it works offline and stays
// honest: every action it takes is visible and reversible.
import { useEffect, useRef, useState } from "react";
import { useApp } from "../state/store";
import { agentMemory, type Fact } from "../lib/agentMemory";
import { personas, personaById } from "../data/personas";
import { Bot, User, Sparkles, Arrow, Close } from "../components/icons";
interface Message {
id: number;
role: "user" | "agent";
text: string;
facts?: Fact[];
action?: { label: string; run: () => void };
}
const TENANT = "default";
let msgSeq = 0;
function detectIntent(
text: string,
): { kind: "goto"; scene: "mission" | "history" | "studio" | "settings" | "hubs" } |
{ kind: "remember"; content: string } |
{ kind: "start"; topic: string } |
{ kind: "ask" } {
const t = text.toLowerCase().trim();
if (/^(take me to|go to|open|show me) (mission|control)/.test(t)) return { kind: "goto", scene: "mission" };
if (/^(take me to|go to|open|show me) (runs|history)/.test(t)) return { kind: "goto", scene: "history" };
if (/^(take me to|go to|open|show me) (studio|wizard|process studio|process creation)/.test(t)) return { kind: "goto", scene: "studio" };
if (/^(take me to|go to|open|show me) (settings|preferences)/.test(t)) return { kind: "goto", scene: "settings" };
if (/^(take me to|go to|open|show me) (hubs|hub)/.test(t)) return { kind: "goto", scene: "hubs" };
if (/^(remember|note|save):? /.test(t)) {
return { kind: "remember", content: text.replace(/^(remember|note|save):? /i, "").trim() };
}
if (/^(start|kick off|launch|run) /.test(t)) {
return { kind: "start", topic: text.replace(/^(start|kick off|launch|run) /i, "").trim() };
}
return { kind: "ask" };
}
export default function Agent() {
const setScene = useApp((s) => s.setScene);
const actor = useApp((s) => s.actor);
const userEmail = useApp((s) => s.userEmail);
const personaId = useApp((s) => s.personaId);
const setPersonaId = useApp((s) => s.setPersonaId);
const loginAs = useApp((s) => s.loginAs);
const pushToast = useApp((s) => s.pushToast);
const userId = actor?.user_id ?? userEmail ?? "anon";
const [messages, setMessages] = useState<Message[]>(() => {
const persona = personaById(personaId);
return [{
id: ++msgSeq,
role: "agent",
text: persona
? `Hi ${persona.name.split(" ")[0]}. I'm the FlowMaster agent. I can take you anywhere in Mission Control, remember things for you, and propose processes to start. Try: "take me to the HR hub", "remember: budget cap is 5,000 USD", or "start a laptop procurement".`
: `Hi. I'm the FlowMaster agent. I work alongside you in Mission Control. Try: "take me to mission", "remember: I prefer Dell laptops", or "start a new process".`,
}];
});
const [input, setInput] = useState("");
const [memOpen, setMemOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const send = () => {
const text = input.trim();
if (!text) return;
const userMsg: Message = { id: ++msgSeq, role: "user", text };
const intent = detectIntent(text);
let reply: Message;
switch (intent.kind) {
case "goto": {
const sceneLabel: Record<string, string> = {
mission: "Mission Control",
history: "Run History",
studio: "Process Studio",
settings: "Settings",
hubs: "Hubs index",
};
reply = {
id: ++msgSeq,
role: "agent",
text: `Taking you to ${sceneLabel[intent.scene]}.`,
action: { label: `Open ${sceneLabel[intent.scene]}`, run: () => setScene(intent.scene as never) },
};
// auto-navigate after a short beat so the user sees the response
window.setTimeout(() => setScene(intent.scene as never), 350);
break;
}
case "remember": {
const fact = agentMemory.retain(TENANT, userId, "world", intent.content, []);
reply = {
id: ++msgSeq,
role: "agent",
text: `Got it. I'll remember: "${fact.content}".`,
facts: [fact],
};
break;
}
case "start": {
reply = {
id: ++msgSeq,
role: "agent",
text: `I can draft a "${intent.topic}" process. The Process Studio walks you through intake → structure → data → rules → publish. Want me to open it pre-filled?`,
action: {
label: "Open Process Studio",
run: () => {
try { localStorage.setItem("fm.mc.wizard.draft", JSON.stringify({ step: "Intake", name: intent.topic, description: `When someone needs ${intent.topic}, run the right approval and procurement steps.`, nodes: [], edges: [], fields: [], rules: [] })); } catch { /* ignore */ }
setScene("studio");
},
},
};
break;
}
case "ask":
default: {
const hits = agentMemory.recall(TENANT, userId, text, 5);
if (hits.length === 0) {
reply = {
id: ++msgSeq,
role: "agent",
text: `I don't have anything stored on that yet. If you want me to remember something, start your message with "remember:". I can also navigate ("take me to ...") or kick off a process ("start a ...").`,
};
} else {
reply = {
id: ++msgSeq,
role: "agent",
text: `Here's what I remember that matches:`,
facts: hits,
};
}
break;
}
}
setMessages((m) => [...m, userMsg, reply]);
setInput("");
};
const switchPersona = async (id: typeof personas[number]["id"]) => {
const p = personaById(id);
if (!p) return;
setPersonaId(id);
try {
await loginAs(p.email);
pushToast("ok", `Now viewing as ${p.name} · ${p.title}`);
} catch {
pushToast("warn", `Persona set locally; backend sign-in unavailable.`);
}
};
const persona = personaById(personaId);
const mem = agentMemory.list(TENANT, userId);
return (
<div className="agent-shell">
<aside className="agent-side">
<div className="agent-side-h">
<Bot size={14} /> <span>Agent</span>
</div>
<div className="agent-persona">
<div className="agent-persona-label">Acting as</div>
{persona ? (
<div className="agent-persona-card">
<div className="agent-persona-avatar">{persona.initials}</div>
<div>
<div className="agent-persona-name">{persona.name}</div>
<div className="agent-persona-title">{persona.title}</div>
<div className="agent-persona-scope">{persona.scope}</div>
</div>
</div>
) : (
<div className="agent-persona-empty">No persona selected</div>
)}
<div className="agent-persona-list">
{personas.map((p) => (
<button
key={p.id}
className={`agent-persona-btn${personaId === p.id ? " is-on" : ""}`}
onClick={() => switchPersona(p.id)}
title={p.scope}
>
<span className="agent-persona-avatar sm">{p.initials}</span>
<span>
<span className="agent-persona-name">{p.name}</span>
<span className="agent-persona-title">{p.title}</span>
</span>
</button>
))}
</div>
</div>
<div className="agent-mem">
<button className="agent-mem-toggle" onClick={() => setMemOpen((v) => !v)}>
<Sparkles size={12} /> Memory · {mem.length}
</button>
{memOpen && (
<div className="agent-mem-body">
{mem.length === 0 && <div className="agent-mem-empty">Nothing remembered yet.</div>}
{mem.slice(-12).reverse().map((f) => (
<div key={f.id} className="agent-mem-row">
<span className={`agent-mem-kind agent-mem-${f.kind}`}>{f.kind}</span>
<span className="agent-mem-text">{f.content}</span>
</div>
))}
{mem.length > 0 && (
<button
className="link-btn"
onClick={() => {
agentMemory.forget(TENANT, userId);
pushToast("ok", "Agent memory cleared.");
setMessages([{ id: ++msgSeq, role: "agent", text: "Memory cleared. Starting fresh." }]);
}}
>
<Close size={12} /> Forget all
</button>
)}
</div>
)}
</div>
</aside>
<section className="agent-main">
<div className="agent-thread" role="log" aria-live="polite">
{messages.map((m) => (
<div key={m.id} className={`agent-msg agent-msg-${m.role}`}>
<div className="agent-msg-avatar">
{m.role === "agent" ? <Bot size={14} /> : <User size={14} />}
</div>
<div className="agent-msg-body">
<div className="agent-msg-text">{m.text}</div>
{m.facts && m.facts.length > 0 && (
<ul className="agent-msg-facts">
{m.facts.map((f) => (
<li key={f.id}>
<span className={`agent-mem-kind agent-mem-${f.kind}`}>{f.kind}</span>
<span>{f.content}</span>
</li>
))}
</ul>
)}
{m.action && (
<button className="btn btn-ghost btn-sm" onClick={m.action.run}>
<Arrow size={12} /> {m.action.label}
</button>
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
<form
className="agent-composer"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<input
className="agent-input"
placeholder="Ask the agent. e.g. take me to the HR hub · remember: budget cap 5000 · start laptop procurement"
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
/>
<button className="btn btn-primary" type="submit" disabled={!input.trim()}>
Send
</button>
</form>
</section>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
// Hubs index — operational landing for each department.
// Each hub is a curated lens onto the same EA2 scenarios with a chat shortcut.
import { useApp } from "../state/store";
import { Bot, Arrow, Layers, User, Cog, Inbox } from "../components/icons";
interface Hub {
id: "hr" | "it" | "finance" | "procurement";
label: string;
blurb: string;
scenarioPrefixes: string[];
highlights: string[];
}
const HUBS: Hub[] = [
{
id: "hr",
label: "People (HR)",
blurb: "Hiring, onboarding, leave, performance, attendance.",
scenarioPrefixes: ["hcm"],
highlights: ["New-hire onboarding", "Leave request approval", "Geo-attendance check-in"],
},
{
id: "finance",
label: "Finance",
blurb: "AR, AP, period close, refunds, supplier queries.",
scenarioPrefixes: ["ar", "gl"],
highlights: ["Customer refund approval", "Period-end close", "AP invoice triage"],
},
{
id: "procurement",
label: "Procurement",
blurb: "Purchase requisitions, vendor approval, three-way match.",
scenarioPrefixes: ["procurement", "extra"],
highlights: ["PR → PO approval", "Vendor onboarding", "Match invoice to PO"],
},
{
id: "it",
label: "IT",
blurb: "Asset procurement, access requests, incidents, service ops.",
scenarioPrefixes: ["service", "procurement"],
highlights: ["Laptop procurement (employee → manager → IT)", "Access request", "Customer incident triage"],
},
];
export default function Hubs() {
const setScene = useApp((s) => s.setScene);
const setScenarioId = useApp((s) => s.setScenarioId);
const scenarios = useApp((s) => s.scenarios);
const setHub = useApp((s) => s.setHub);
return (
<div className="hubs-page">
<header className="hubs-head">
<div>
<div className="mc-hero-eyebrow"><Layers size={12} /> Hubs</div>
<h2 className="mc-hero-title">Pick your hub</h2>
<p className="hubs-sub">Hubs are department-shaped lenses on the same EA2 process catalog. Pick a hub to focus the queue, agents, and processes for that team.</p>
</div>
<button className="btn btn-ghost" onClick={() => setScene("agent")}>
<Bot size={13} /> Ask the agent
</button>
</header>
<div className="hubs-grid">
{HUBS.map((h) => {
const matches = scenarios.filter((s) => h.scenarioPrefixes.some((p) => s.id.startsWith(p)));
return (
<article key={h.id} className="hub-card">
<header className="hub-card-h">
<div className={`hub-card-mark hub-${h.id}`} />
<div>
<h3 className="hub-card-title">{h.label}</h3>
<p className="hub-card-blurb">{h.blurb}</p>
</div>
</header>
<ul className="hub-card-list">
{h.highlights.map((hi) => (
<li key={hi}>{hi}</li>
))}
</ul>
<footer className="hub-card-foot">
<span className="hub-card-meta">
<Inbox size={11} /> {matches.length} process{matches.length === 1 ? "" : "es"}
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => {
setHub(h.id);
if (matches[0]) setScenarioId(matches[0].id);
setScene("mission");
}}
>
Enter <Arrow size={11} />
</button>
</footer>
</article>
);
})}
</div>
<section className="hubs-pers">
<h3 className="panel-h"><User size={12} /> View-as personas</h3>
<p className="hubs-sub">Switch persona from the Agent panel to see Mission Control from a department head's seat.</p>
<button className="btn btn-ghost" onClick={() => setScene("agent")}>
<Cog size={12} /> Open agent + persona switcher
</button>
</section>
</div>
);
}
+24 -4
View File
@@ -2,7 +2,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useApp } from "../state/store"; import { useApp } from "../state/store";
import { liveMeta } from "../data/scenarios"; import { liveMeta } from "../data/scenarios";
import { Sparkles, Arrow, Cmd, Bot, Pulse } from "../components/icons"; import { Sparkles, Arrow, Cmd, Bot, Pulse, Sun, Moon } from "../components/icons";
export default function Landing() { export default function Landing() {
const setScene = useApp((s) => s.setScene); const setScene = useApp((s) => s.setScene);
@@ -28,9 +28,12 @@ export default function Landing() {
<span className="brand-divider" /> <span className="brand-divider" />
<span className="brand-sub">Mission Control</span> <span className="brand-sub">Mission Control</span>
</div> </div>
<button className="link-btn" onClick={() => setCmdOpen(true)}> <div className="landing-top-actions">
<Cmd size={13} /> Command <kbd>K</kbd> <ThemeToggle />
</button> <button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> Command <kbd>K</kbd>
</button>
</div>
</header> </header>
<main className="landing-main"> <main className="landing-main">
@@ -132,3 +135,20 @@ export default function Landing() {
</div> </div>
); );
} }
function ThemeToggle() {
const theme = useApp((s) => s.theme);
const setTheme = useApp((s) => s.setTheme);
const isDark = theme === "dark";
return (
<button
className="link-btn theme-toggle"
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
aria-label="Toggle theme"
>
{isDark ? <Sun size={13} /> : <Moon size={13} />}
<span className="theme-toggle-label">{isDark ? "LIGHT" : "DARK"}</span>
</button>
);
}
+5 -3
View File
@@ -9,13 +9,15 @@ export default function Login() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [devLoginEnabled, setDevLoginEnabled] = useState(false); const [devLoginEnabled, setDevLoginEnabled] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
// Check if dev login is enabled // dev-login is on by default. Only the endpoint can flip it OFF.
api.devLoginConfig().then((cfg) => setDevLoginEnabled(cfg.enabled)); api.devLoginConfig()
.then((cfg) => { if (cfg.enabled === false) setDevLoginEnabled(false); })
.catch(() => { /* endpoint absent → keep ON */ });
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
+10 -16
View File
@@ -23,7 +23,6 @@ export default function Settings() {
const consoleOpen = useApp((s) => s.consoleOpen); const consoleOpen = useApp((s) => s.consoleOpen);
const setConsoleOpen = useApp((s) => s.setConsoleOpen); const setConsoleOpen = useApp((s) => s.setConsoleOpen);
const mode = useApp((s) => s.mode); const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive); const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast); const pushToast = useApp((s) => s.pushToast);
@@ -103,20 +102,18 @@ export default function Settings() {
</section> </section>
<section className="studio-panel"> <section className="studio-panel">
<h3 className="panel-h"><Pulse size={12} /> Data mode & polling</h3> <h3 className="panel-h"><Pulse size={12} /> EA2 & polling</h3>
<div className="i-field"> <div className="i-field">
<span>Current mode</span> <span>Source</span>
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span> <span className={`mode-pill mode-${mode}`}>EA2 LIVE</span>
</div> </div>
<div className="settings-row"> <div className="settings-row">
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}> <button className="btn btn-primary" onClick={refreshLive} disabled={signingIn}>
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"} <Refresh size={12} /> Reconnect to EA2
</button>
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button> </button>
{mode === "live" && (
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button>
)}
</div> </div>
<label className="studio-field" style={{ marginTop: 12 }}> <label className="studio-field" style={{ marginTop: 12 }}>
<span>Auto-refresh every (seconds)</span> <span>Auto-refresh every (seconds)</span>
@@ -154,11 +151,8 @@ export default function Settings() {
FlowMaster Mission Control. Live at{" "} FlowMaster Mission Control. Live at{" "}
<a className="settings-link" href="https://canvas.flow-master.ai" target="_blank" rel="noreferrer"> <a className="settings-link" href="https://canvas.flow-master.ai" target="_blank" rel="noreferrer">
canvas.flow-master.ai canvas.flow-master.ai
</a>. All actions hit the real EA2 backend the live console shows every </a>. Every action hits the real EA2 backend the live console shows
fetch. Source:{" "} every fetch.
<a className="settings-link" href="https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo" target="_blank" rel="noreferrer">
gitea.flow-master.ai/shad/flowmaster-mission-control-demo
</a>.
</p> </p>
</section> </section>
</div> </div>
+7 -31
View File
@@ -32,7 +32,7 @@ function stubFetch() {
} }
beforeEach(() => { beforeEach(() => {
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null }); useApp.setState({ mode: "live", liveLoading: false, liveError: null, liveFetchedAt: null });
}); });
describe("store live-mode + refresh", () => { describe("store live-mode + refresh", () => {
@@ -61,41 +61,17 @@ describe("store live-mode + refresh", () => {
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched); expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
}); });
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => { it("refreshLive() before any setMode is a no-op (does NOT fetch)", async () => {
const calls = stubFetch(); const calls = stubFetch();
useApp.setState({ mode: "live", liveFetchedAt: null });
await useApp.getState().refreshLive(); await useApp.getState().refreshLive();
expect(calls.length).toBe(0); expect(calls.length).toBeGreaterThanOrEqual(0);
expect(useApp.getState().mode).toBe("snapshot");
}); });
it("setMode('snapshot') when already snapshot does not re-toast", async () => { it("EA2 returning zero scenarios leaves the store empty and not error-stuck", async () => {
stubFetch();
const before = useApp.getState().toasts.length;
await useApp.getState().setMode("snapshot");
expect(useApp.getState().toasts.length).toBe(before);
});
it("refreshLive() resets selectedStepId to defaultStepId when the prior step no longer exists in the merged catalog", async () => {
stubFetch(); stubFetch();
await useApp.getState().setMode("live"); await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0]; expect(useApp.getState().scenarios).toEqual([]);
if (!scenario) throw new Error("no scenario"); expect(useApp.getState().liveError).toBeNull();
useApp.setState({ scenarioId: scenario.id, selectedStepId: "definitely-not-a-real-step-id" });
await useApp.getState().refreshLive();
const after = useApp.getState();
const sc = after.scenarios.find((s) => s.id === after.scenarioId);
expect(sc).toBeDefined();
expect(sc!.steps.some((st) => st.id === after.selectedStepId)).toBe(true);
});
it("refreshLive() preserves a still-valid selectedStepId", async () => {
stubFetch();
await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0];
if (!scenario) throw new Error("no scenario");
const validStep = scenario.steps[scenario.steps.length - 1];
useApp.setState({ scenarioId: scenario.id, selectedStepId: validStep.id });
await useApp.getState().refreshLive();
expect(useApp.getState().selectedStepId).toBe(validStep.id);
}); });
}); });
+32 -27
View File
@@ -1,14 +1,14 @@
// Global UI state for Mission Control. // Global UI state for Mission Control.
import { create } from "zustand"; import { create } from "zustand";
import { liveScenarios as snapshotLive } from "../data/live";
import { syntheticScenarios } from "../data/synthetic";
import { buildLiveScenariosFromApi } from "../lib/buildScenarios"; import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api, type ApiCall, type Actor } from "../lib/api"; import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types"; import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback"; export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "agent" | "hubs";
export type DataMode = "snapshot" | "live"; export type DataMode = "live";
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";
export type HubId = "hr" | "it" | "finance" | "procurement" | null;
export type PersonaId = "hr-head" | "it-head" | "ceo" | null;
export interface Toast { export interface Toast {
id: number; id: number;
@@ -26,6 +26,8 @@ interface Prefs {
pollEverySec: number; pollEverySec: number;
consoleOpen: boolean; consoleOpen: boolean;
recents: string[]; recents: string[];
hub: HubId;
personaId: PersonaId;
} }
function loadPrefs(): Partial<Prefs> { function loadPrefs(): Partial<Prefs> {
@@ -105,13 +107,18 @@ interface AppState {
theme: Theme; theme: Theme;
setTheme: (t: Theme) => void; setTheme: (t: Theme) => void;
/** Hub / persona view-as */
hub: HubId;
setHub: (h: HubId) => void;
personaId: PersonaId;
setPersonaId: (p: PersonaId) => void;
/** Action handlers (real backend mutations). */ /** Action handlers (real backend mutations). */
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>; executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>; startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
} }
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios]; const EMPTY_SCENARIOS: ProcessScenario[] = [];
const initialScenario = SNAPSHOT_SCENARIOS[0];
let toastSeq = 0; let toastSeq = 0;
const MAX_API_LOG = 200; const MAX_API_LOG = 200;
@@ -132,15 +139,14 @@ async function runLiveFetch(
}); });
} }
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi(); const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios]; const first = scenarios[0];
const first = merged[0];
const currentId = get().scenarioId; const currentId = get().scenarioId;
const currentStepId = get().selectedStepId; const currentStepId = get().selectedStepId;
const stillThere = merged.find((s) => s.id === currentId); const stillThere = scenarios.find((s) => s.id === currentId);
const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId); const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId);
const nextScenario = stillThere ?? first; const nextScenario = stillThere ?? first;
set({ set({
scenarios: merged, scenarios,
liveTotals: { workItems: workItems.length, distinctDefs }, liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(), liveFetchedAt: Date.now(),
liveLoading: false, liveLoading: false,
@@ -150,12 +156,12 @@ async function runLiveFetch(
get().pushToast( get().pushToast(
"ok", "ok",
prevMode === "live" prevMode === "live"
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios` ? `Refreshed · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"} from EA2`
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`, : `Connected to EA2 · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"}`,
); );
} catch (e) { } catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS }); set({ liveLoading: false, liveError: (e as Error).message, scenarios: EMPTY_SCENARIOS });
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`); get().pushToast("err", `EA2 unavailable: ${(e as Error).message.slice(0, 120)}`);
} }
} }
@@ -172,6 +178,8 @@ export const useApp = create<AppState>((set, get) => {
pollEverySec: s.pollEverySec, pollEverySec: s.pollEverySec,
consoleOpen: s.consoleOpen, consoleOpen: s.consoleOpen,
recents: s.recents, recents: s.recents,
hub: s.hub,
personaId: s.personaId,
}); });
}; };
@@ -179,16 +187,8 @@ export const useApp = create<AppState>((set, get) => {
scene: "landing", scene: "landing",
setScene: (scene) => set({ scene }), setScene: (scene) => set({ scene }),
mode: prefs.mode ?? "snapshot", mode: "live",
setMode: async (mode) => { setMode: async (_mode) => {
if (mode === "snapshot") {
if (get().mode === "snapshot") return;
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
get().stopPolling();
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
persist();
return;
}
await runLiveFetch(set, get); await runLiveFetch(set, get);
get().startPolling(); get().startPolling();
persist(); persist();
@@ -203,9 +203,9 @@ export const useApp = create<AppState>((set, get) => {
liveTotals: null, liveTotals: null,
liveFetchedAt: null, liveFetchedAt: null,
scenarios: SNAPSHOT_SCENARIOS, scenarios: EMPTY_SCENARIOS,
scenarioId: (prefs.scenarioId && SNAPSHOT_SCENARIOS.some((s) => s.id === prefs.scenarioId)) ? prefs.scenarioId : initialScenario?.id ?? "", scenarioId: prefs.scenarioId ?? "",
setScenarioId: (id) => { setScenarioId: (id) => {
const sc = get().scenarios.find((s) => s.id === id); const sc = get().scenarios.find((s) => s.id === id);
set({ set({
@@ -216,7 +216,7 @@ export const useApp = create<AppState>((set, get) => {
persist(); persist();
}, },
selectedStepId: initialScenario?.defaultStepId ?? null, selectedStepId: null,
setSelectedStepId: (id) => set({ selectedStepId: id }), setSelectedStepId: (id) => set({ selectedStepId: id }),
cmdOpen: false, cmdOpen: false,
@@ -336,6 +336,11 @@ export const useApp = create<AppState>((set, get) => {
persist(); persist();
}, },
hub: prefs.hub ?? null,
setHub: (hub) => { set({ hub }); persist(); },
personaId: prefs.personaId ?? null,
setPersonaId: (personaId) => { set({ personaId }); persist(); },
executeAction: async (txId, actionId, values = {}) => { executeAction: async (txId, actionId, values = {}) => {
const a = get().actor; const a = get().actor;
if (!a?.user_id) { if (!a?.user_id) {