feat: Add login and sso callback scenes
Some checks failed
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

This commit is contained in:
Shad 2026-06-14 03:46:27 +04:00
parent b2c1e57c86
commit 163ae21135
27 changed files with 2274 additions and 275 deletions

View File

@ -1,31 +1,55 @@
map $sent_http_content_type $csp_header {
default "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://demo.flow-master.ai https://canvas.flow-master.ai wss://canvas.flow-master.ai; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Reverse-proxy /api to the demo backend so live mode works
# same-origin without CORS gymnastics. Backend is reached via the
# cluster-internal service rewritten by the ingress to demo.flow-master.ai.
# Hardening every response carries these.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(self), geolocation=(self), payment=()" always;
add_header Content-Security-Policy $csp_header always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header X-Robots-Tag "noindex, nofollow" always;
# Reverse-proxy /api to the demo backend so live mode is same-origin.
location /api/ {
proxy_pass https://demo.flow-master.ai;
proxy_set_header Host demo.flow-master.ai;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_ssl_server_name on;
proxy_http_version 1.1;
proxy_read_timeout 30s;
proxy_buffering off;
}
# SPA: everything else falls back to index.html.
# SPA fallback to index.html for client-side routing.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
add_header Cache-Control "no-cache" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(self), geolocation=(self), payment=()" always;
add_header Content-Security-Policy $csp_header always;
}
# Static assets with content hashes in their filenames cache forever.
# Hashed static assets cache forever.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Hide nginx version on errors.
server_tokens off;
}

View File

@ -26,6 +26,8 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -34,6 +36,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"happy-dom": "^20.10.3",
"jsdom": "^29.1.1",
"playwright": "^1.60.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",

563
pnpm-lock.yaml generated
View File

@ -36,6 +36,12 @@ importers:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.5.0)
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@types/node':
specifier: ^24.12.3
version: 24.13.2
@ -60,6 +66,12 @@ importers:
globals:
specifier: ^17.6.0
version: 17.6.0
happy-dom:
specifier: ^20.10.3
version: 20.10.3
jsdom:
specifier: ^29.1.1
version: 29.1.1
playwright:
specifier: ^1.60.0
version: 1.60.0
@ -74,10 +86,28 @@ importers:
version: 8.0.16(@types/node@24.13.2)
vitest:
specifier: ^4.1.8
version: 4.1.8(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2))
version: 4.1.8(@types/node@24.13.2)(happy-dom@20.10.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2))
packages:
'@adobe/css-tools@4.5.0':
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@7.1.1':
resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/generational-cache@1.0.1':
resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@ -133,6 +163,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@ -145,6 +179,46 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.2.1':
resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.1.3':
resolution: {integrity: sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.5':
resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@ -193,6 +267,15 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@ -546,9 +629,35 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/jest-dom@6.9.1':
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -674,6 +783,12 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.61.0':
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -788,10 +903,25 @@ packages:
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -805,6 +935,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
@ -814,6 +947,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
@ -837,6 +974,13 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -881,6 +1025,10 @@ packages:
dagre@0.8.5:
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -890,9 +1038,16 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -900,9 +1055,23 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
electron-to-chromium@1.5.372:
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@ -1050,12 +1219,20 @@ packages:
graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
happy-dom@20.10.3:
resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==}
engines: {node: '>=20.0.0'}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -1068,6 +1245,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -1076,12 +1257,24 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsdom@29.1.1:
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@ -1189,12 +1382,27 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@ -1236,6 +1444,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -1272,6 +1483,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -1281,6 +1496,9 @@ packages:
peerDependencies:
react: ^19.2.7
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -1321,11 +1539,23 @@ packages:
react: '>=17'
react-dom: '>=17'
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -1359,6 +1589,13 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -1374,6 +1611,21 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tldts-core@7.4.2:
resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==}
tldts@7.4.2:
resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==}
hasBin: true
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@ -1402,6 +1654,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.27.2:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
engines: {node: '>=20.18.1'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@ -1520,6 +1776,26 @@ packages:
jsdom:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -1534,6 +1810,25 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -1585,6 +1880,28 @@ packages:
snapshots:
'@adobe/css-tools@4.5.0': {}
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@asamuzakjp/dom-selector@7.1.1':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
'@asamuzakjp/generational-cache@1.0.1': {}
'@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@ -1662,6 +1979,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
'@babel/runtime@7.29.7': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@ -1685,6 +2004,34 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@ -1735,6 +2082,8 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
'@exodus/bytes@1.15.1': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@ -2052,11 +2401,43 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/jest-dom@6.9.1':
dependencies:
'@adobe/css-tools': 4.5.0
aria-query: 5.3.2
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
optional: true
'@types/aria-query@5.0.4': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -2203,6 +2584,12 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.13.2
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -2353,16 +2740,30 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@5.2.0: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
aria-query@5.3.2: {}
assertion-error@2.0.1: {}
balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.37: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@ -2375,6 +2776,10 @@ snapshots:
node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-image-size@0.6.4:
dependencies:
'@types/node': 24.13.2
caniuse-lite@1.0.30001799: {}
chai@6.2.2: {}
@ -2401,6 +2806,13 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
css.escape@1.5.1: {}
csstype@3.2.3: {}
d3-color@3.1.0: {}
@ -2444,18 +2856,37 @@ snapshots:
graphlib: 2.1.8
lodash: 4.18.1
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
electron-to-chromium@1.5.372: {}
entities@7.0.1: {}
entities@8.0.0: {}
es-module-lexer@2.1.0: {}
escalade@3.2.0: {}
@ -2602,28 +3033,77 @@ snapshots:
dependencies:
lodash: 4.18.1
happy-dom@20.10.3:
dependencies:
'@types/node': 24.13.2
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
dependencies:
hermes-estree: 0.25.1
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
transitivePeerDependencies:
- '@noble/hashes'
ignore@5.3.2: {}
ignore@7.0.5: {}
imurmurhash@0.1.4: {}
indent-string@4.0.0: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-potential-custom-element-name@1.0.1: {}
isexe@2.0.0: {}
js-tokens@4.0.0: {}
jsdom@29.1.1:
dependencies:
'@asamuzakjp/css-color': 5.1.11
'@asamuzakjp/dom-selector': 7.1.1
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.1
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.27.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@ -2698,14 +3178,22 @@ snapshots:
lodash@4.18.1: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mdn-data@2.27.1: {}
min-indent@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@ -2743,6 +3231,10 @@ snapshots:
dependencies:
p-limit: 3.1.0
parse5@8.0.1:
dependencies:
entities: 8.0.0
path-exists@4.0.0: {}
path-key@3.1.1: {}
@ -2769,6 +3261,12 @@ snapshots:
prelude-ls@1.2.1: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
punycode@2.3.1: {}
react-dom@19.2.7(react@19.2.7):
@ -2776,6 +3274,8 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
react-is@17.0.2: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
dependencies:
react: 19.2.7
@ -2819,6 +3319,13 @@ snapshots:
- '@types/react'
- immer
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
require-from-string@2.0.2: {}
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@ -2840,6 +3347,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
semver@6.3.1: {}
@ -2860,6 +3371,12 @@ snapshots:
std-env@4.1.0: {}
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
symbol-tree@3.2.4: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
@ -2871,6 +3388,20 @@ snapshots:
tinyrainbow@3.1.0: {}
tldts-core@7.4.2: {}
tldts@7.4.2:
dependencies:
tldts-core: 7.4.2
tough-cookie@6.0.1:
dependencies:
tldts: 7.4.2
tr46@6.0.0:
dependencies:
punycode: 2.3.1
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@ -2896,6 +3427,8 @@ snapshots:
undici-types@7.18.2: {}
undici@7.27.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@ -2936,7 +3469,7 @@ snapshots:
'@types/node': 24.13.2
fsevents: 2.3.3
vitest@4.1.8(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)):
vitest@4.1.8(@types/node@24.13.2)(happy-dom@20.10.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@24.13.2)):
dependencies:
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@24.13.2))
@ -2960,9 +3493,29 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.2
happy-dom: 20.10.3
jsdom: 29.1.1
transitivePeerDependencies:
- msw
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
webidl-conversions@8.0.1: {}
whatwg-mimetype@3.0.0: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
which@2.0.2:
dependencies:
isexe: 2.0.0
@ -2974,6 +3527,12 @@ snapshots:
word-wrap@1.2.5: {}
ws@8.21.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yallist@3.1.1: {}
yocto-queue@0.1.0: {}

248
qa/human_qa.mjs Normal file
View File

@ -0,0 +1,248 @@
// Real human-style QA against https://canvas.flow-master.ai.
// Drives the live site through every flow a person would touch and
// captures: console errors, network errors, real action round-trips,
// identity switching, dark/light, mobile width, screenshot evidence.
//
// Bypasses local DNS cache by mapping to the LB IP directly.
import { chromium, devices } from "playwright";
import { mkdirSync, writeFileSync } from "node:fs";
const URL = "https://canvas.flow-master.ai/";
const FORCE_IP = "65.21.71.186";
const OUT = "qa/screenshots/human";
mkdirSync(OUT, { recursive: true });
const findings = [];
const note = (kind, where, msg) => {
const f = { kind, where, msg };
findings.push(f);
console.log(`[${kind}] ${where} :: ${msg}`);
};
async function attach(page, label) {
page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200)));
page.on("console", (m) => {
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("response", (r) => {
if (r.status() >= 400) note("http", label, `${r.status()} ${r.url()}`);
});
}
const browser = await chromium.launch({
headless: true,
args: [
`--host-resolver-rules=MAP canvas.flow-master.ai ${FORCE_IP}`,
"--ignore-certificate-errors",
],
});
// =========================================================================
// FLOW 1 — desktop landing → mission → real action round-trip
// =========================================================================
{
console.log("\n=== FLOW 1: desktop full walkthrough ===");
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
const p = await ctx.newPage();
await attach(p, "F1");
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.screenshot({ path: `${OUT}/f1-01-landing.png` });
// R1.S6: scan the page for the word 'demo'
const visibleText = await p.evaluate(() => document.body.innerText);
const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length;
if (demoHits > 0) note("de-demo", "landing", `'demo' appears ${demoHits} times in visible text`);
// 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();
}
// =========================================================================
// FLOW 2 — mobile viewport (regional store manager on a phone)
// =========================================================================
{
console.log("\n=== FLOW 2: mobile (iPhone 14 Pro) ===");
const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true });
const p = await ctx.newPage();
await attach(p, "F2-mobile");
await p.goto(URL, { waitUntil: "networkidle" });
await p.screenshot({ path: `${OUT}/f2-01-mobile-landing.png` });
const horizontalScroll = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2);
if (horizontalScroll) note("mobile", "landing", "horizontal scroll present on mobile — layout overflow");
const cards = await p.locator(".sc-card").count();
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();
}
// =========================================================================
// FLOW 3 — reload + deep state check (do we lose mode/scenario across reload?)
// =========================================================================
{
console.log("\n=== FLOW 3: persistence across reload ===");
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 p = await ctx.newPage();
const resp = await p.goto(URL);
const h = resp?.headers() ?? {};
const required = [
"strict-transport-security",
"x-content-type-options",
"x-frame-options",
"referrer-policy",
"content-security-policy",
];
for (const k of required) {
if (!h[k]) note("security", "headers", `missing ${k}`);
else note("ok", "headers", `${k}: ${h[k].slice(0, 80)}`);
}
await ctx.close();
}
await browser.close();
// Write the findings dump
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; }, {});
console.log("\n=== SUMMARY ===");
console.log(JSON.stringify(byKind, null, 2));
console.log(`\n${findings.length} findings written to ${OUT}/findings.json`);
console.log(`→ screenshots in ${OUT}/`);

View File

@ -9,6 +9,7 @@ const DOCTRINE = new Set([
"#1a2740", "#243453",
"#4a5b80", "#7a8aa8",
"#c46a14", "#3d6a2c", "#a6342a", "#1d6f82",
"#0c1322", "#e6edf7",
]);
function fail(msg) { console.log("✗", msg); process.exitCode = 1; }

3
qa/test.mjs Normal file
View File

@ -0,0 +1,3 @@
import fs from 'fs';
const css = fs.readFileSync('src/index.css', 'utf-8');
console.log(css.includes('[data-theme="dark"]'));

1
setupTests.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -4,12 +4,14 @@ import { useApp, scenarioById } from "./state/store";
import Landing from "./scenes/Landing";
import MissionControl from "./scenes/MissionControl";
import RunHistory from "./scenes/RunHistory";
import Studio from "./scenes/Studio";
import Wizard from "./scenes/Wizard";
import Settings from "./scenes/Settings";
import Login from "./scenes/Login";
import SsoCallback from "./scenes/SsoCallback";
import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster";
import Console from "./components/Console";
import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User } from "./components/icons";
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon } from "./components/icons";
import { liveMeta } from "./data/scenarios";
export default function App() {
@ -30,6 +32,16 @@ export default function App() {
const userEmail = useApp((s) => s.userEmail);
const startPolling = useApp((s) => s.startPolling);
const stopPolling = useApp((s) => s.stopPolling);
const isAuthed = useApp((s) => s.isAuthed);
// Auth guard and SSO callback detector
useEffect(() => {
if (typeof window !== "undefined" && window.location.hash.includes("access_token")) {
setScene("sso-callback");
} else if (!isAuthed && scene !== "login" && scene !== "sso-callback") {
setScene("login");
}
}, [isAuthed, scene, setScene]);
useEffect(() => {
if (mode === "live") startPolling();
@ -40,7 +52,7 @@ export default function App() {
return (
<div className={`shell shell-${scene}`}>
{scene !== "landing" && (
{scene !== "landing" && scene !== "login" && scene !== "sso-callback" && (
<header className="topbar" data-anchor="topbar">
<button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home">
<span className="brand-mark sm" />
@ -121,6 +133,9 @@ export default function App() {
<Layers size={12} /> Console
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
</button>
<button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme">
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
</button>
<button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> <kbd>K</kbd>
</button>
@ -129,10 +144,12 @@ export default function App() {
)}
<div className="scene">
{scene === "login" && <Login />}
{scene === "sso-callback" && <SsoCallback />}
{scene === "landing" && <Landing />}
{scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />}
{scene === "studio" && <Studio />}
{scene === "studio" && <Wizard />}
{scene === "settings" && <Settings />}
</div>

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo } from "react";
import { Command } from "cmdk";
import { useApp, scenarioById } from "../state/store";
import { Play, Branch, Bot, Check, Home, History as HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
import { Play, Branch, Bot, Check, Home, HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
export default function CommandBar() {
const open = useApp((s) => s.cmdOpen);
@ -80,7 +80,7 @@ export default function CommandBar() {
<HistoryIcon size={13} /> Run History
</Command.Item>
<Command.Item onSelect={() => { setScene("studio"); close(); }}>
<Branch size={13} /> Process Studio
<Branch size={13} /> Process Wizard
<span className="cmd-hint">design & publish a new process</span>
</Command.Item>
<Command.Item onSelect={() => { setScene("settings"); close(); }}>
@ -120,7 +120,7 @@ export default function CommandBar() {
onSelect={async () => {
close();
if (mode !== "live") { pushToast("warn", "Switch to LIVE mode to start a real instance."); return; }
await startInstance(sc.defKey, `MC demo · ${new Date().toLocaleString()}`);
await startInstance(sc.defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
}}
>
<Play size={13} /> Start new instance of "{sc.defName}"

View File

@ -1,7 +1,7 @@
// Tabbed Inspector for the selected step.
import { useMemo, useState } from "react";
import { useApp, scenarioById } from "../state/store";
import { Shield, Doc, Layers, Pulse, History } from "./icons";
import { Shield, Doc, Layers, Pulse, HistoryIcon } from "./icons";
const STATE_LABEL: Record<string, string> = {
done: "Done", running: "Running", queued: "Queued",
@ -12,7 +12,7 @@ const TABS: Array<{ id: "overview" | "rules" | "evidence" | "raw" | "runs"; labe
{ id: "overview", label: "Overview", Icon: Doc },
{ id: "rules", label: "Rules", Icon: Shield },
{ id: "evidence", label: "Evidence", Icon: Pulse },
{ id: "runs", label: "Runs", Icon: History },
{ id: "runs", label: "Runs", Icon: HistoryIcon },
{ id: "raw", label: "Raw", Icon: Layers },
];

View File

@ -133,7 +133,7 @@ function StartInstanceButton({ defKey, live }: { defKey: string; live: boolean }
}
setBusy(true);
try {
await startInstance(defKey, `MC demo · ${new Date().toLocaleString()}`);
await startInstance(defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
} finally { setBusy(false); }
};
return (

View File

@ -24,6 +24,9 @@ export const Doc = (p: P) => <Svg {...p}><path d="M7 3h7l4 4v14H7zM14 3v4h4" /><
export const Cog = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1" /></Svg>;
export const Flag = (p: P) => <Svg {...p}><path d="M5 21V4h13l-2 4 2 4H5" /></Svg>;
export const Arrow = (p: P) => <Svg {...p}><path d="M5 12h14M13 5l7 7-7 7" /></Svg>;
export const Sun = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></Svg>;
export const Moon = (p: P) => <Svg {...p}><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></Svg>;
export const ArrowUp = (p: P) => <Svg {...p}><path d="M12 19V5M5 12l7-7 7 7" /></Svg>;
export const ArrowDown = (p: P) => <Svg {...p}><path d="M12 5v14M5 12l7 7 7-7" /></Svg>;
export const Sparkles = (p: P) => <Svg {...p}><path d="M12 3l1.7 4.3L18 9l-4.3 1.7L12 15l-1.7-4.3L6 9l4.3-1.7zM19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9z" /></Svg>;
@ -35,5 +38,6 @@ export const Search = (p: P) => <Svg {...p}><circle cx="11" cy="11" r="7" /><pat
export const Spark = (p: P) => <Svg {...p}><polyline points="3,16 8,11 12,15 17,8 21,12" /></Svg>;
export const Pulse = (p: P) => <Svg {...p}><path d="M3 12h4l3-8 4 16 3-8h4" /></Svg>;
export const Layers = (p: P) => <Svg {...p}><path d="M12 3l9 5-9 5-9-5zM3 13l9 5 9-5M3 18l9 5 9-5" /></Svg>;
export const History = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
export const HistoryIcon = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
export const Home = (p: P) => <Svg {...p}><path d="M4 11l8-7 8 7v9h-5v-6h-6v6H4z" /></Svg>;
export const ChevronRight = (p: P) => <Svg {...p}><path d="M9 18l6-6-6-6" /></Svg>;

View File

@ -81,7 +81,7 @@ function baseTour(familyId: string): TourStep[] {
{ id: "t1", anchor: "graph", title: `${familyId} industry blueprint`, body: `This is the canonical ${familyId} process modelled in FlowMaster's typed format — start, agent/service/human steps, decision branches, rules, evidence. Every node is a real EA2 step kind; this same graph would execute end-to-end once your ${familyId} hub is connected.` },
{ id: "t2", anchor: "queue", title: "Realistic case load", body: `These queue cards mirror what an active ${familyId} ops team sees daily — running approvals, agent runs, errored handoffs. Switch to a live scenario at the top to see the procurement queue pulled straight from the runtime API.` },
{ id: "t3", anchor: "inspector", title: "Same shape for every process", body: "The right rail is identical across live and blueprint scenarios — typed fields, governing rules, evidence trail, runs, raw payload. That's the point: one inspector, every process family." },
{ id: "t4", anchor: "command", title: "Drive the demo with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
{ id: "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." },
];

View File

@ -8,6 +8,17 @@
- light paper canvas + navy frame + amber accent
- 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 {
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
--bp-paper: #f5f7fb;
@ -108,6 +119,8 @@ kbd {
Topbar (industrial instrument strip)
===================================================================== */
.topbar {
position: relative;
z-index: 190;
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: stretch;
@ -1143,6 +1156,198 @@ select.studio-input { background: var(--bp-paper); }
.toast-warn { border-left-color: var(--bp-amber); }
.toast-err { border-left-color: var(--bp-err); }
/* =====================================================================
Login Page
===================================================================== */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bp-paper);
background-image: linear-gradient(var(--bp-grid-minor) 1px, transparent 1px),
linear-gradient(90deg, var(--bp-grid-minor) 1px, transparent 1px);
background-size: 20px 20px;
}
.login-card {
width: 380px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
padding: 24px 32px;
position: relative;
}
.login-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 18px; height: 18px;
border-right: 1px solid var(--bp-navy);
border-bottom: 1px solid var(--bp-navy);
background: var(--bp-paper-2);
}
.login-head {
text-align: center;
margin-bottom: 24px;
}
.login-head .brand-mark {
display: inline-block;
width: 24px; height: 24px;
background: var(--bp-amber);
border: 1px solid var(--bp-navy);
margin-bottom: 12px;
}
.login-head h1 {
margin: 0;
font-family: var(--bp-mono);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--bp-navy);
}
.login-sub {
margin-top: 4px;
font-size: 10px;
color: var(--bp-muted);
letter-spacing: 0.15em;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.login-field label {
font-size: 10px;
font-weight: 700;
color: var(--bp-muted);
letter-spacing: 0.1em;
}
.login-input {
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
padding: 8px 12px;
font-family: var(--bp-mono);
font-size: 13px;
color: var(--bp-navy);
}
.login-input:focus {
outline: 2px solid var(--bp-amber);
outline-offset: 0;
}
.login-input:disabled {
opacity: 0.6;
background: var(--bp-paper-2);
}
.login-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: -4px;
}
.login-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--bp-navy);
cursor: pointer;
}
.login-checkbox input {
accent-color: var(--bp-amber);
width: 14px;
height: 14px;
}
.login-link {
font-size: 11px;
color: var(--bp-amber);
text-decoration: underline;
}
.login-actions {
margin-top: 8px;
}
.login-actions .btn {
width: 100%;
}
.login-divider {
display: flex;
align-items: center;
text-align: center;
margin: 24px 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--bp-navy);
opacity: 0.2;
}
.login-divider span {
padding: 0 10px;
font-size: 10px;
color: var(--bp-muted);
letter-spacing: 0.1em;
}
.login-sso-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.sso-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
color: var(--bp-navy);
font-family: var(--bp-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
transition: background 0.1s;
}
.sso-btn:hover:not(:disabled) {
background: var(--bp-paper-2);
}
.dev-login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background: var(--bp-paper-2);
border: 1px dashed var(--bp-navy);
color: var(--bp-muted);
font-family: var(--bp-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
transition: background 0.1s, color 0.1s;
}
.dev-login-btn:hover:not(:disabled) {
background: var(--bp-paper-3);
color: var(--bp-navy);
}
.dev-login-btn:disabled, .sso-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
margin-bottom: 16px;
padding: 8px 12px;
background: var(--bp-err-soft);
border-left: 2px solid var(--bp-err);
color: var(--bp-err);
font-size: 11px;
}
/* =====================================================================
Scrollbars
===================================================================== */
@ -1347,3 +1552,238 @@ select.studio-input { background: var(--bp-paper); }
[data-canvas="blueprint"] .react-flow__edge.selected .react-flow__edge-path,
[data-canvas="blueprint"] .react-flow__edge:focus .react-flow__edge-path { stroke: var(--bp-amber); }
[data-canvas="blueprint"] .react-flow__attribution { display: none; }
/* Wizard */
.wizard-container {
display: flex;
flex-direction: column;
gap: var(--space-xl);
padding: var(--space-xl) var(--space-2xl);
height: 100%;
overflow-y: auto;
}
.wizard-header {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.wizard-progress {
display: flex;
align-items: center;
gap: var(--space-xs);
background: var(--paper);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.wizard-step-marker {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-3);
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
.wizard-step-marker.active {
background: var(--amber);
color: var(--navy);
font-weight: 600;
}
.wizard-step-marker.past {
color: var(--text-1);
background: var(--ok-soft);
}
.step-num {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: currentColor;
color: var(--paper);
font-size: 9px;
font-weight: 700;
}
.wizard-step-marker.active .step-num {
background: var(--navy);
color: var(--amber);
}
.wizard-step-marker.past .step-num {
background: var(--ok);
color: var(--paper);
}
.step-chevron {
color: var(--border);
margin-left: var(--space-xs);
}
.wizard-content {
display: flex;
flex-direction: column;
gap: var(--space-xl);
max-width: 800px;
}
.wizard-panel {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.wizard-panel.success-panel {
align-items: center;
text-align: center;
border-color: var(--ok);
background: var(--ok-soft);
}
.success-icon {
color: var(--ok);
margin-bottom: var(--space-sm);
}
.field-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field-group label {
font-size: 13px;
color: var(--text-2);
font-weight: 500;
}
.field-group input, .field-group textarea {
background: var(--paper-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
color: var(--text-1);
font-family: var(--font-sans);
font-size: 14px;
}
.field-group input:focus, .field-group textarea:focus {
outline: none;
border-color: var(--amber);
}
.warning-text {
color: var(--warn);
font-size: 12px;
margin-top: var(--space-xs);
}
.node-list, .field-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-card, .field-row {
display: flex;
align-items: center;
gap: var(--space-md);
background: var(--paper-2);
border: 1px solid var(--border);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
flex: 1;
}
.node-idx {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-3);
background: var(--border);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.node-header input, .field-row input {
background: transparent;
border: 1px solid transparent;
color: var(--text-1);
font-family: var(--font-sans);
font-size: 14px;
width: 100%;
padding: var(--space-xs) var(--space-sm);
}
.node-header input:hover, .field-row input:hover {
border-color: var(--border);
}
.node-header input:focus, .field-row input:focus {
outline: none;
border-color: var(--amber);
background: var(--paper);
}
.node-meta select, .field-row select {
background: var(--paper);
border: 1px solid var(--border);
color: var(--text-2);
font-size: 13px;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
.summary-stats {
display: flex;
gap: var(--space-xl);
padding: var(--space-md);
background: var(--paper-2);
border-radius: var(--radius-sm);
}
.summary-stats div {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
font-size: 18px;
font-weight: 500;
color: var(--text-1);
}
.summary-stats span {
font-size: 11px;
text-transform: uppercase;
color: var(--text-3);
letter-spacing: 0.5px;
}
.actions-row {
display: flex;
gap: var(--space-md);
align-items: center;
}

View File

@ -174,6 +174,13 @@ async function authedRequest<T>(
export const api = {
config: DEFAULT_CONFIG,
/** Set the bearer token explicitly (e.g. from SSO or login form) */
setBearer(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
// Also set a non-httpOnly cookie for server-side middleware
document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax`;
},
/** Subscribe to all API calls. Returns unsubscribe. */
onCall(fn: ApiCallObserver): () => void {
observers.add(fn);
@ -184,6 +191,38 @@ export const api = {
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
},
async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
const payload = password ? { email, password } : { email };
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(payload),
signal,
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload);
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
}
const data = await r.json() as { access_token: string; refresh_token?: string };
this.setBearer(data.access_token);
return data;
},
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> {
try {
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
if (r.ok) {
return await r.json() as { enabled: boolean };
}
} catch {
// swallow
}
return { enabled: false };
},
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal);
return body.items ?? [];
@ -256,7 +295,7 @@ export const api = {
{
kind: "definition",
status: "published",
source_context: "mc-demo-studio",
source_context: "mission-control-studio",
version: 1,
...payload,
},

80
src/lib/wizardApi.ts Normal file
View File

@ -0,0 +1,80 @@
import { api, type Actor } from "./api";
export interface CreateDraftPayload {
name: string;
display_name: string;
description: string;
source_context: string;
config: {
wizard: {
marker: string;
maxDepth: number;
maxNodes: number;
chat: any[];
uploads: any[];
panelState: any;
debug: any[];
};
};
}
export interface BatchOperation {
collection: string;
op: "insert" | "update" | "delete";
_key?: string;
_from?: string;
_to?: string;
[key: string]: any;
}
export const wizardApi = {
async createDraft(payload: CreateDraftPayload, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
method: "POST",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "definition",
status: "draft",
...payload,
}),
signal,
});
if (!res.ok) throw new Error(`createDraft failed: ${res.status}`);
return res.json() as Promise<{ _key: string; name: string }>;
},
async updateDraftConfig(key: string, patch: any, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify(patch),
signal,
});
if (!res.ok) throw new Error(`updateDraftConfig failed: ${res.status}`);
return res.json();
},
async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ flow_key, ops, actor }),
signal,
});
if (!res.ok) throw new Error(`applyBatch failed: ${res.status}`);
return res.json();
},
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) {
return api.startTransaction(process_definition_id, business_subject, signal);
}
};

View File

@ -46,12 +46,10 @@ export default function Landing() {
</h1>
<p className="hero-sub">
FlowMaster turns the operational map of a company into living,
typed processes backed by humans, agents, and rules and gives you a
single command-center to drive them. Procurement scenarios are real,
backed by EA2 on{" "}
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>;
AR, HCM, GL, and Service are industry blueprints showing how this
same shell extends to any process family.
typed processes backed by humans, agents, and rules and gives you
one control surface to drive them. Every procurement, finance, people,
and service workflow runs end-to-end through EA2 on{" "}
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "the FlowMaster backend"}</span>.
</p>
<div className="hero-actions">
<button className="btn btn-primary btn-lg" onClick={() => setScene("mission")}>
@ -129,7 +127,7 @@ export default function Landing() {
</main>
<footer className="landing-foot">
<span className="foot-eyebrow">FlowMaster · Mission Control demo · synthesised on top of demo.flow-master.ai</span>
<span className="foot-eyebrow">FlowMaster · Mission Control · live operator surface for FlowMaster processes</span>
</footer>
</div>
);

101
src/scenes/Login.test.tsx Normal file
View File

@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import Login from "./Login";
import { useApp } from "../state/store";
import { api } from "../lib/api";
// Mock dependencies
vi.mock("../state/store", () => ({
useApp: vi.fn(),
}));
vi.mock("../lib/api", () => ({
api: {
devLoginConfig: vi.fn(),
},
}));
describe("Login Scene", () => {
const mockSetScene = vi.fn();
const mockLoginAs = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default store mock
(useApp as any).mockImplementation((selector: any) => {
const state = {
setScene: mockSetScene,
loginAs: mockLoginAs,
};
return selector(state);
});
// Default api mock
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
});
it("renders standard login form elements", async () => {
render(<Login />);
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined();
});
it("calls loginAs and setScene on valid form submission", async () => {
mockLoginAs.mockResolvedValueOnce(undefined);
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123');
expect(mockSetScene).toHaveBeenCalledWith('landing');
});
});
it("displays error on login failure", async () => {
mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials"));
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(screen.getByText("Invalid credentials")).toBeDefined();
expect(mockSetScene).not.toHaveBeenCalled();
});
});
it("shows dev-login button when feature flag is enabled", async () => {
(api.devLoginConfig as any).mockResolvedValueOnce({ enabled: true });
render(<Login />);
await waitFor(() => {
expect(screen.getByText(/DEV-LOGIN/i)).toBeDefined();
});
});
it("redirects to SSO endpoint on microsoft click", async () => {
// We can't actually assert on window.location.href in this test environment
// without mocking window.location, but we can verify the button renders
// and click it to ensure no errors
render(<Login />);
const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0];
fireEvent.click(ssoBtn);
// We just verify it doesn't try to call the regular login
expect(mockLoginAs).not.toHaveBeenCalled();
});
});

141
src/scenes/Login.tsx Normal file
View File

@ -0,0 +1,141 @@
import { useState, useEffect } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { Bot } from "../components/icons";
export default function Login() {
const setScene = useApp((s) => s.setScene);
const loginAs = useApp((s) => s.loginAs);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [devLoginEnabled, setDevLoginEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Check if dev login is enabled
api.devLoginConfig().then((cfg) => setDevLoginEnabled(cfg.enabled));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setLoading(true);
setError(null);
try {
await loginAs(email, password || undefined);
setScene("landing");
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleDevLogin = async () => {
if (!email) {
setError("Email required for dev-login");
return;
}
setLoading(true);
setError(null);
try {
await loginAs(email, undefined);
setScene("landing");
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleSSO = () => {
window.location.href = "/api/v1/auth/microsoft/login";
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-head">
<span className="brand-mark"></span>
<h1>FLOWMASTER AUTHENTICATION</h1>
<div className="login-sub">MISSION CONTROL VERIFICATION REQUIRED</div>
</div>
{error && <div className="login-error">{error}</div>}
<form className="login-form" onSubmit={handleSubmit}>
<div className="login-field">
<label htmlFor="email">OPERATOR_ID (EMAIL)</label>
<input
id="email"
type="email"
className="login-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
autoComplete="email"
placeholder="e.g. j.doe@flow-master.ai"
required
/>
</div>
<div className="login-field">
<label htmlFor="password">PASSPHRASE</label>
<input
id="password"
type="password"
className="login-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
autoComplete="current-password"
/>
</div>
<div className="login-row">
<label className="login-checkbox">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={loading}
/>
<span>Remember me</span>
</label>
<a href="#" className="login-link">Recover access</a>
</div>
<div className="login-actions">
<button type="submit" className="btn btn-primary btn-lg" disabled={loading || !email}>
{loading ? "VERIFYING..." : "SIGN IN"}
</button>
</div>
</form>
<div className="login-divider">
<span>OR</span>
</div>
<div className="login-sso-actions">
<button type="button" className="sso-btn" onClick={handleSSO} disabled={loading}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
CONTINUE WITH MICROSOFT
</button>
{devLoginEnabled && (
<button type="button" className="dev-login-btn" onClick={handleDevLogin} disabled={loading || !email}>
<Bot size={14} /> SIGN IN AS DEVELOPER (DEV-LOGIN)
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
// RunHistory scene: cross-scenario timeline.
import { useMemo, useState } from "react";
import { useApp } from "../state/store";
import { Clock, History } from "../components/icons";
import { Clock, HistoryIcon } from "../components/icons";
const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
type Status = (typeof STATUS_FILTERS)[number];
@ -36,7 +36,7 @@ export default function RunHistory() {
<div className="rh">
<header className="rh-head">
<div>
<span className="mc-hero-eyebrow"><History size={12} /> Run history</span>
<span className="mc-hero-eyebrow"><HistoryIcon size={12} /> Run history</span>
<h2 className="mc-hero-title">All scenarios · all runs</h2>
</div>
<nav className="rh-filters">

View File

@ -0,0 +1,51 @@
import { useEffect } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
export default function SsoCallback() {
const setScene = useApp((s) => s.setScene);
useEffect(() => {
// Process fragment identifier
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");
if (accessToken) {
// Clean URL hash so tokens aren't left in the address bar
window.history.replaceState(null, "", window.location.pathname + window.location.search);
// Store token
api.setBearer(accessToken);
// Update state
useApp.setState({ isAuthed: true });
// Fetch user profile and sync state
api.me().then((me) => {
useApp.setState({
actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null,
userEmail: me.email
});
useApp.getState().pushToast("ok", `Signed in as ${me.email}`);
setScene("landing");
}).catch(err => {
useApp.getState().pushToast("err", `Failed to get profile: ${err.message}`);
setScene("login");
});
} else {
useApp.getState().pushToast("err", "No access token found in URL");
setScene("login");
}
}, [setScene]);
return (
<div className="login-page">
<div className="login-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px' }}>
<span className="spin" style={{ width: '24px', height: '24px', marginBottom: '16px' }}></span>
<div className="mono" style={{ fontSize: '12px', letterSpacing: '0.1em' }}>PROCESSING AUTHENTICATION...</div>
</div>
</div>
);
}

View File

@ -1,237 +0,0 @@
// Studio scene — design a new FlowMaster process and publish it to EA2.
import { useState } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { Branch, Play, Check, Close, Bot, User, Cog } from "../components/icons";
type NodeKind = "start" | "human_task" | "agent_task" | "service_task" | "end";
interface DraftNode {
id: string;
type: NodeKind;
label: string;
}
interface DraftEdge {
id: string;
source: string;
target: string;
}
const DEFAULT_NODES: DraftNode[] = [
{ id: "start", type: "start", label: "Request received" },
{ id: "review", type: "human_task", label: "Manager review" },
{ id: "approved", type: "end", label: "Approved" },
];
const DEFAULT_EDGES: DraftEdge[] = [
{ id: "e1", source: "start", target: "review" },
{ id: "e2", source: "review", target: "approved" },
];
const KIND_ICON: Record<NodeKind, (p: { size?: number }) => React.ReactElement> = {
start: Branch,
end: Branch,
human_task: User,
agent_task: Bot,
service_task: Cog,
};
const ORG_ID = "a0000000-0000-0000-0000-000000000010";
export default function Studio() {
const mode = useApp((s) => s.mode);
const pushToast = useApp((s) => s.pushToast);
const refreshLive = useApp((s) => s.refreshLive);
const actor = useApp((s) => s.actor);
const setScene = useApp((s) => s.setScene);
const [name, setName] = useState("my-process-" + Math.random().toString(36).slice(2, 8));
const [displayName, setDisplayName] = useState("My Process");
const [hub, setHub] = useState("procurement");
const [description, setDescription] = useState("Demo process designed in the Studio.");
const [nodes, setNodes] = useState<DraftNode[]>(DEFAULT_NODES);
const [edges, setEdges] = useState<DraftEdge[]>(DEFAULT_EDGES);
const [publishing, setPublishing] = useState(false);
const [lastResult, setLastResult] = useState<{ ok: boolean; key?: string; err?: string } | null>(null);
const canPublish = mode === "live" && !!actor?.user_id;
const addNode = () => {
const idx = nodes.length;
setNodes([...nodes, { id: `n${idx}`, type: "human_task", label: `New step ${idx}` }]);
};
const removeNode = (id: string) => {
setNodes(nodes.filter((n) => n.id !== id));
setEdges(edges.filter((e) => e.source !== id && e.target !== id));
};
const updateNode = (id: string, patch: Partial<DraftNode>) => {
setNodes(nodes.map((n) => (n.id === id ? { ...n, ...patch } : n)));
};
const addEdge = () => {
if (nodes.length < 2) return;
const idx = edges.length;
setEdges([...edges, { id: `e${idx + 1}`, source: nodes[0].id, target: nodes[nodes.length - 1].id }]);
};
const removeEdge = (id: string) => setEdges(edges.filter((e) => e.id !== id));
const updateEdge = (id: string, patch: Partial<DraftEdge>) => {
setEdges(edges.map((e) => (e.id === id ? { ...e, ...patch } : e)));
};
const publish = async () => {
if (!canPublish) {
pushToast("warn", "Switch to LIVE mode + sign in to publish a process.");
return;
}
setPublishing(true);
setLastResult(null);
try {
const r = await api.createProcess({
name,
display_name: displayName,
label: displayName,
description,
hub,
config: {
org_id: ORG_ID,
executable: true,
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
},
});
pushToast("ok", `Published ${r.name}${r._key.slice(0, 8)}`);
setLastResult({ ok: true, key: r._key });
await refreshLive();
} catch (e) {
pushToast("err", `Publish failed: ${(e as Error).message.slice(0, 140)}`);
setLastResult({ ok: false, err: (e as Error).message });
} finally {
setPublishing(false);
}
};
const valid = nodes.length >= 2 && edges.length >= 1 && nodes.every((n) => n.label.trim().length > 0);
return (
<div className="studio">
<header className="studio-head">
<div>
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Studio</div>
<h2 className="mc-hero-title">Design a new process</h2>
<div className="mc-hero-sub">Hand-craft a typed FlowMaster process and publish it straight to EA2 on demo.flow-master.ai.</div>
</div>
<button className="btn btn-primary btn-lg" onClick={publish} disabled={!valid || publishing}>
{publishing ? <span className="spin" /> : <Play size={13} />} Publish to EA2
{!canPublish && <span className="preview-marker">live + sign-in required</span>}
</button>
</header>
<div className="studio-grid">
<section className="studio-panel">
<h3 className="panel-h">Definition</h3>
<label className="studio-field">
<span>name (unique slug)</span>
<input className="studio-input mono" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="studio-field">
<span>display name</span>
<input className="studio-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</label>
<label className="studio-field">
<span>hub</span>
<select className="studio-input" value={hub} onChange={(e) => setHub(e.target.value)}>
<option value="procurement">procurement</option>
<option value="finance">finance</option>
<option value="people">people</option>
<option value="service">service</option>
</select>
</label>
<label className="studio-field">
<span>description</span>
<textarea className="studio-input" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
</section>
<section className="studio-panel">
<h3 className="panel-h">
Nodes
<span className="panel-count">{nodes.length}</span>
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addNode}>+ node</button>
</h3>
<div className="studio-rows">
{nodes.map((n) => {
const Icon = KIND_ICON[n.type] ?? Cog;
return (
<div key={n.id} className="studio-row">
<span className="studio-row-id mono">{n.id}</span>
<Icon size={13} />
<select className="studio-input" value={n.type} onChange={(e) => updateNode(n.id, { type: e.target.value as NodeKind })}>
<option value="start">start</option>
<option value="human_task">human_task</option>
<option value="agent_task">agent_task</option>
<option value="service_task">service_task</option>
<option value="end">end</option>
</select>
<input className="studio-input" value={n.label} onChange={(e) => updateNode(n.id, { label: e.target.value })} />
<button className="link-btn" onClick={() => removeNode(n.id)} title="Remove"><Close size={11} /></button>
</div>
);
})}
</div>
</section>
<section className="studio-panel">
<h3 className="panel-h">
Edges
<span className="panel-count">{edges.length}</span>
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addEdge}>+ edge</button>
</h3>
<div className="studio-rows">
{edges.map((e) => (
<div key={e.id} className="studio-row">
<span className="studio-row-id mono">{e.id}</span>
<select className="studio-input" value={e.source} onChange={(ev) => updateEdge(e.id, { source: ev.target.value })}>
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
</select>
<span className="mono" style={{ color: "var(--text-3)" }}></span>
<select className="studio-input" value={e.target} onChange={(ev) => updateEdge(e.id, { target: ev.target.value })}>
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
</select>
<button className="link-btn" onClick={() => removeEdge(e.id)} title="Remove"><Close size={11} /></button>
</div>
))}
</div>
</section>
<section className="studio-panel">
<h3 className="panel-h">JSON preview</h3>
<pre className="raw-json"><code>{JSON.stringify({
name, display_name: displayName, hub, description,
kind: "definition", status: "published", version: 1,
config: {
org_id: ORG_ID, executable: true,
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
},
}, null, 2)}</code></pre>
{lastResult && (
<div className={`studio-result ${lastResult.ok ? "ok" : "err"}`}>
{lastResult.ok ? (
<>
<Check size={12} /> Published as <span className="mono">{lastResult.key?.slice(0, 12)}</span>
<button className="link-btn" onClick={() => setScene("mission")} style={{ marginLeft: "auto" }}>Open in Mission Control</button>
</>
) : (
<><Close size={12} /> <span className="mono">{lastResult.err?.slice(0, 200)}</span></>
)}
</div>
)}
</section>
</div>
</div>
);
}

431
src/scenes/Wizard.tsx Normal file
View File

@ -0,0 +1,431 @@
import { useState, useEffect } from "react";
import { useApp } from "../state/store";
import { wizardApi, type BatchOperation } from "../lib/wizardApi";
import { Branch, Check, Close, ChevronRight } from "../components/icons";
type WizardStep = "Intake" | "Analyze" | "Generate" | "Validate" | "Draft saved" | "Publish";
const STEPS: WizardStep[] = ["Intake", "Analyze", "Generate", "Validate", "Draft saved", "Publish"];
interface DraftState {
step: WizardStep;
flowKey?: string;
name: string;
description: string;
nodes: any[];
edges: any[];
fields: { name: string; type: string }[];
rules: string[];
}
export default function Wizard() {
const mode = useApp((s) => s.mode);
const pushToast = useApp((s) => s.pushToast);
const refreshLive = useApp((s) => s.refreshLive);
const actor = useApp((s) => s.actor);
const setScene = useApp((s) => s.setScene);
const canPublish = mode === "live" && !!actor?.user_id;
const [draft, setDraft] = useState<DraftState>(() => {
try {
const stored = localStorage.getItem("fm.mc.wizard.draft");
if (stored) return JSON.parse(stored);
} catch {}
return {
step: "Intake",
name: "",
description: "",
nodes: [],
edges: [],
fields: [],
rules: []
};
});
const [working, setWorking] = useState(false);
const [publishedKey, setPublishedKey] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem("fm.mc.wizard.draft", JSON.stringify(draft));
}, [draft]);
const updateDraft = (patch: Partial<DraftState>) => {
setDraft(d => ({ ...d, ...patch }));
};
const handleIntakeSubmit = async () => {
if (!draft.description) return;
setWorking(true);
try {
const slug = `process_${Date.now()}`;
const res = await wizardApi.createDraft({
name: slug,
display_name: draft.name || "Untitled Process",
description: draft.description,
source_context: "EA2_DRAFT_PROCESS:process_creation",
config: {
wizard: {
marker: "EA2_DRAFT_PROCESS",
maxDepth: 5,
maxNodes: 40,
chat: [],
uploads: [],
panelState: {},
debug: []
}
}
});
// Auto-generate basic 5-step template
const templateNodes = [
{ _key: "n1", kind: "step", display_name: "Capture request details", dispatch_kind: "human" },
{ _key: "n2", kind: "step", display_name: "Validate requirements", dispatch_kind: "agent", agent_capability: "evaluate_business_rule" },
{ _key: "n3", kind: "step", display_name: "Prepare work package", dispatch_kind: "agent", agent_capability: "process_design_assistant" },
{ _key: "n4", kind: "step", display_name: "Review and approve", dispatch_kind: "human" },
{ _key: "n5", kind: "step", display_name: "Close out process", dispatch_kind: "human" }
];
updateDraft({
flowKey: res._key,
step: "Analyze",
nodes: templateNodes
});
pushToast("ok", "Draft created in EA2");
} catch (err: any) {
pushToast("err", `Failed to create draft: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleStructureSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const ops: BatchOperation[] = draft.nodes.map(n => ({
collection: "ea2_node",
op: "insert",
flow_key: draft.flowKey,
...n
}));
// Link them sequentially
for (let i = 0; i < draft.nodes.length - 1; i++) {
ops.push({
collection: "ea2_edge",
op: "insert",
flow_key: draft.flowKey,
_from: `ea2_node/${draft.nodes[i]._key}`,
_to: `ea2_node/${draft.nodes[i+1]._key}`,
kind: "next"
});
}
await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Generate" });
pushToast("ok", "Structure saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleDataSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
// In a real app we'd save proper data models here.
// For the spike we just move forward and do a dummy update
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { fields: draft.fields } } }
});
updateDraft({ step: "Validate" });
pushToast("ok", "Data requirements saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleRulesSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { rules: draft.rules } } }
});
updateDraft({ step: "Draft saved" });
pushToast("ok", "Rules saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handlePublish = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const ops: BatchOperation[] = [
{
collection: "ea2_flow",
op: "update",
_key: draft.flowKey,
status: "published"
}
];
await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Publish" });
setPublishedKey(draft.flowKey);
pushToast("ok", "Process published successfully!");
refreshLive();
} catch (err: any) {
pushToast("err", `Publish failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleStartInstance = async () => {
if(!publishedKey) return;
setWorking(true);
try {
await wizardApi.startInstance(publishedKey);
pushToast("ok", "New instance started!");
setScene("mission");
} catch(err:any) {
pushToast("err", `Failed to start: ${err.message}`);
} finally {
setWorking(false);
}
}
const renderStepNav = () => (
<div className="wizard-progress">
{STEPS.map((step, i) => {
const isActive = step === draft.step;
const isPast = STEPS.indexOf(step) < STEPS.indexOf(draft.step);
return (
<div key={step} className={`wizard-step-marker ${isActive ? 'active' : ''} ${isPast ? 'past' : ''}`}>
<span className="step-num">{i + 1}</span>
<span className="step-label">{step}</span>
{i < STEPS.length - 1 && <ChevronRight size={12} className="step-chevron" />}
</div>
);
})}
</div>
);
return (
<div className="wizard-container">
<header className="wizard-header">
<div>
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Creation Wizard</div>
<h2 className="mc-hero-title">Build a new process</h2>
</div>
{renderStepNav()}
</header>
<div className="wizard-content">
{draft.step === "Intake" && (
<div className="wizard-panel">
<h3 className="panel-h">What process do you want to build?</h3>
<div className="field-group">
<label>Name (optional)</label>
<input
type="text"
value={draft.name}
onChange={e => updateDraft({ name: e.target.value })}
placeholder="e.g. Laptop Procurement"
/>
</div>
<div className="field-group">
<label>Describe the process</label>
<textarea
value={draft.description}
onChange={e => updateDraft({ description: e.target.value })}
placeholder="When a store manager requests a new laptop, run a procurement approval..."
rows={4}
/>
</div>
<button
className="btn btn-primary"
onClick={handleIntakeSubmit}
disabled={!draft.description || working || !canPublish}
title="POST /api/ea2/flow"
>
{working ? 'Saving...' : 'Start Drafting →'}
</button>
{!canPublish && <p className="warning-text">Switch to LIVE mode + sign in to create processes.</p>}
</div>
)}
{draft.step === "Analyze" && (
<div className="wizard-panel">
<h3 className="panel-h">Review proposed structure</h3>
<div className="node-list">
{draft.nodes.map((n, i) => (
<div key={n._key} className="node-card">
<div className="node-header">
<span className="node-idx">{i + 1}</span>
<input
value={n.display_name}
onChange={e => {
const newNodes = [...draft.nodes];
newNodes[i].display_name = e.target.value;
updateDraft({ nodes: newNodes });
}}
/>
</div>
<div className="node-meta">
<select
value={n.dispatch_kind}
onChange={e => {
const newNodes = [...draft.nodes];
newNodes[i].dispatch_kind = e.target.value;
updateDraft({ nodes: newNodes });
}}
>
<option value="human">Human Task</option>
<option value="agent">Agent Task</option>
<option value="system">System Task</option>
</select>
</div>
</div>
))}
</div>
<button
className="btn btn-primary"
onClick={handleStructureSave}
disabled={working}
title="POST /api/ea2/apply-batch"
>
{working ? 'Saving...' : 'Confirm Structure →'}
</button>
</div>
)}
{draft.step === "Generate" && (
<div className="wizard-panel">
<h3 className="panel-h">What information do you need to collect?</h3>
<div className="field-list">
{draft.fields.map((f, i) => (
<div key={i} className="field-row">
<input
placeholder="Field name"
value={f.name}
onChange={e => {
const newFields = [...draft.fields];
newFields[i].name = e.target.value;
updateDraft({ fields: newFields });
}}
/>
<select
value={f.type}
onChange={e => {
const newFields = [...draft.fields];
newFields[i].type = e.target.value;
updateDraft({ fields: newFields });
}}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">Yes/No</option>
</select>
<button className="icon-btn" onClick={() => updateDraft({ fields: draft.fields.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ fields: [...draft.fields, { name: "", type: "string" }] })}>+ Add Field</button>
</div>
<button
className="btn btn-primary"
onClick={handleDataSave}
disabled={working}
title="PUT /api/ea2/flow/{key}"
>
{working ? 'Saving...' : 'Confirm Data →'}
</button>
</div>
)}
{draft.step === "Validate" && (
<div className="wizard-panel">
<h3 className="panel-h">When should this process do something different? (Rules)</h3>
<div className="field-list">
{draft.rules.map((r, i) => (
<div key={i} className="field-row">
<input
placeholder="e.g. If cost > 5000, require VP approval"
value={r}
onChange={e => {
const newRules = [...draft.rules];
newRules[i] = e.target.value;
updateDraft({ rules: newRules });
}}
style={{ flex: 1 }}
/>
<button className="icon-btn" onClick={() => updateDraft({ rules: draft.rules.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ rules: [...draft.rules, ""] })}>+ Add Rule</button>
</div>
<button
className="btn btn-primary"
onClick={handleRulesSave}
disabled={working}
title="PUT /api/ea2/flow/{key}"
>
{working ? 'Saving...' : 'Review Process →'}
</button>
</div>
)}
{draft.step === "Draft saved" && (
<div className="wizard-panel">
<h3 className="panel-h">Review & Publish</h3>
<p>Your process <strong>{draft.name || 'Untitled'}</strong> is ready to be published to EA2.</p>
<div className="summary-stats">
<div><span>Steps:</span> {draft.nodes.length}</div>
<div><span>Data fields:</span> {draft.fields.length}</div>
<div><span>Rules:</span> {draft.rules.length}</div>
</div>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => updateDraft({ step: "Intake" })}>Edit</button>
<button
className="btn btn-primary"
onClick={handlePublish}
disabled={working}
title="POST /api/ea2/apply-batch"
>
{working ? 'Publishing...' : 'Publish to EA2'}
</button>
</div>
</div>
)}
{draft.step === "Publish" && (
<div className="wizard-panel success-panel">
<div className="success-icon"><Check size={32} /></div>
<h3 className="panel-h">Process Published!</h3>
<p>Process definition <code>{publishedKey}</code> is now active in EA2.</p>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => { setDraft({ step: "Intake", name: "", description: "", nodes: [], edges: [], fields: [], rules: [] }); setPublishedKey(null); }}>Create Another</button>
<button
className="btn btn-primary"
onClick={handleStartInstance}
disabled={working}
title="POST /api/runtime/transactions"
>
Start First Instance Now
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -6,7 +6,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light";
@ -82,15 +82,18 @@ interface AppState {
actor: Actor | null;
userEmail: string;
userDisplayName: string | null;
isAuthed: boolean;
setUserEmail: (e: string) => void;
loginAs: (email: string) => Promise<void>;
loginAs: (email: string, password?: string) => Promise<void>;
/** Live polling */
/** Live polling lightweight tick that only refreshes the active
* scenario's work-items + headline runtime. Full refresh is manual. */
pollEverySec: number;
setPollEverySec: (n: number) => void;
pollTimer: number | null;
startPolling: () => void;
stopPolling: () => void;
pollLiveTick: () => Promise<void>;
/** API call log */
consoleOpen: boolean;
@ -240,28 +243,32 @@ export const useApp = create<AppState>((set, get) => {
actor: null,
userEmail: prefs.email ?? "dev@flow-master.ai",
userDisplayName: null,
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
loginAs: async (email) => {
loginAs: async (email, password) => {
api.clearToken();
api.config = { ...api.config, email };
set({ userEmail: email });
persist();
try {
await api.signIn(email, password);
const me = await api.me();
set({
actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null,
isAuthed: true,
});
get().pushToast("ok", `Signed in as ${me.email}`);
if (get().mode === "live") await get().refreshLive();
} catch (e) {
get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`);
throw e;
}
},
pollEverySec: prefs.pollEverySec ?? 8,
pollEverySec: prefs.pollEverySec ?? 30,
setPollEverySec: (n) => {
set({ pollEverySec: Math.max(2, Math.min(120, n)) });
set({ pollEverySec: Math.max(5, Math.min(300, n)) });
persist();
if (get().pollTimer) { get().stopPolling(); get().startPolling(); }
},
@ -273,7 +280,7 @@ export const useApp = create<AppState>((set, get) => {
const id = window.setInterval(() => {
const s = get();
if (s.mode !== "live" || s.liveLoading) return;
void s.refreshLive();
void s.pollLiveTick();
}, get().pollEverySec * 1000);
set({ pollTimer: id });
},
@ -283,6 +290,39 @@ export const useApp = create<AppState>((set, get) => {
if (id) window.clearInterval(id);
set({ pollTimer: null });
},
pollLiveTick: async () => {
const s = get();
if (s.mode !== "live" || s.liveLoading) return;
try {
const workItems = await api.workItems();
const sc = s.scenarios.find((x) => x.id === s.scenarioId);
let headlineRt = null;
if (sc?.headlineTx) {
headlineRt = await api.transaction(sc.headlineTx);
}
const updatedScenarios = s.scenarios.map((scen) => {
if (!scen.live) return scen;
const myCases = workItems.filter((w) => w.definition_key === scen.defKey);
const newQueue = myCases.slice(0, 8).map((c, i) => ({
id: `${c.transaction_id || scen.defKey}-${i}`,
stepId: scen.queue[i]?.stepId ?? scen.defaultStepId,
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
waitingOn: (c.status === "running" || c.status === "waiting_for_user") ? "approval" as const
: c.status === "waiting_for_agent" ? "agent" as const
: "input" as const,
ageDays: c.age_days ?? 0,
status: c.status,
}));
if (scen.id === s.scenarioId && headlineRt) {
return { ...scen, queue: newQueue, raw: { ...(scen.raw as object), headlineRt } };
}
return { ...scen, queue: newQueue };
});
set({ scenarios: updatedScenarios, liveTotals: { workItems: workItems.length, distinctDefs: s.liveTotals?.distinctDefs ?? 0 }, liveFetchedAt: Date.now() });
} catch {
// silent — full refresh will retry
}
},
consoleOpen: prefs.consoleOpen ?? false,
setConsoleOpen: (v) => { set({ consoleOpen: v }); persist(); },

42
update_theme.js Normal file
View File

@ -0,0 +1,42 @@
import fs from 'fs';
let css = fs.readFileSync('src/index.css', 'utf-8');
const darkThemeBlock = `
[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;
}
`;
css = css.replace(':root {', darkThemeBlock + '\n:root {');
fs.writeFileSync('src/index.css', css);
let app = fs.readFileSync('src/App.tsx', 'utf-8');
// Check if Sun/Moon imports exist
app = app.replace(
'import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User } from "./components/icons";',
'import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon } from "./components/icons";'
);
const themeToggle = `
<button
className="link-btn"
onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")}
title="Toggle theme"
>
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
</button>
`;
app = app.replace(
'{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}\n </button>',
'{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}\n </button>\n <button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme">\n {useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}\n </button>'
);
fs.writeFileSync('src/App.tsx', app);

11
vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./setupTests.ts']
}
})

1
vitest.setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';