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 { server {
listen 80; listen 80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Reverse-proxy /api to the demo backend so live mode works # Hardening every response carries these.
# same-origin without CORS gymnastics. Backend is reached via the add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# cluster-internal service rewritten by the ingress to demo.flow-master.ai. 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/ { location /api/ {
proxy_pass https://demo.flow-master.ai; proxy_pass https://demo.flow-master.ai;
proxy_set_header Host demo.flow-master.ai; proxy_set_header Host demo.flow-master.ai;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_ssl_server_name on; proxy_ssl_server_name on;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_read_timeout 30s; 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 / { location / {
try_files $uri $uri/ /index.html; 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/ { location /assets/ {
try_files $uri =404; 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": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -34,6 +36,8 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.10.3",
"jsdom": "^29.1.1",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",

563
pnpm-lock.yaml generated
View File

@ -36,6 +36,12 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(eslint@10.5.0) 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': '@types/node':
specifier: ^24.12.3 specifier: ^24.12.3
version: 24.13.2 version: 24.13.2
@ -60,6 +66,12 @@ importers:
globals: globals:
specifier: ^17.6.0 specifier: ^17.6.0
version: 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: playwright:
specifier: ^1.60.0 specifier: ^1.60.0
version: 1.60.0 version: 1.60.0
@ -74,10 +86,28 @@ importers:
version: 8.0.16(@types/node@24.13.2) version: 8.0.16(@types/node@24.13.2)
vitest: vitest:
specifier: ^4.1.8 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: 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': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -133,6 +163,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.29.7': '@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -145,6 +179,46 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'} 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': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@ -193,6 +267,15 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} 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': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -546,9 +629,35 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 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': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -674,6 +783,12 @@ packages:
'@types/react@19.2.17': '@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} 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': '@typescript-eslint/eslint-plugin@8.61.0':
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -788,10 +903,25 @@ packages:
ajv@6.15.0: ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} 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: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} 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: assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -805,6 +935,9 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
brace-expansion@5.0.6: brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@ -814,6 +947,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true 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: caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
@ -837,6 +974,13 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} 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: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -881,6 +1025,10 @@ packages:
dagre@0.8.5: dagre@0.8.5:
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} 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: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -890,9 +1038,16 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} 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: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -900,9 +1055,23 @@ packages:
detect-node-es@1.1.0: detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} 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: electron-to-chromium@1.5.372:
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} 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: es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@ -1050,12 +1219,20 @@ packages:
graphlib@2.1.8: graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} 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: hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1: hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} 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: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -1068,6 +1245,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} 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: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -1076,12 +1257,24 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} 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: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1189,12 +1382,27 @@ packages:
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} 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: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} 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: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@ -1236,6 +1444,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1272,6 +1483,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} 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: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1281,6 +1496,9 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.7 react: ^19.2.7
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-remove-scroll-bar@2.3.8: react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1321,11 +1539,23 @@ packages:
react: '>=17' react: '>=17'
react-dom: '>=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: rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -1359,6 +1589,13 @@ packages:
std-env@4.1.0: std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} 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: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -1374,6 +1611,21 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'} 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: ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@ -1402,6 +1654,10 @@ packages:
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} 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: update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true hasBin: true
@ -1520,6 +1776,26 @@ packages:
jsdom: jsdom:
optional: true 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: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1534,6 +1810,25 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} 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: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -1585,6 +1880,28 @@ packages:
snapshots: 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': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@ -1662,6 +1979,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@babel/runtime@7.29.7': {}
'@babel/template@7.29.7': '@babel/template@7.29.7':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@ -1685,6 +2004,34 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 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': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@ -1735,6 +2082,8 @@ snapshots:
'@eslint/core': 1.2.1 '@eslint/core': 1.2.1
levn: 0.4.1 levn: 0.4.1
'@exodus/bytes@1.15.1': {}
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
@ -2052,11 +2401,43 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@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': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/aria-query@5.0.4': {}
'@types/chai@5.2.3': '@types/chai@5.2.3':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@ -2203,6 +2584,12 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 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)': '@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: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@ -2353,16 +2740,30 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.4.1 uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@5.2.0: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
aria-query@5.3.2: {}
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.37: {} baseline-browser-mapping@2.10.37: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
brace-expansion@5.0.6: brace-expansion@5.0.6:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@ -2375,6 +2776,10 @@ snapshots:
node-releases: 2.0.47 node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2) 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: {} caniuse-lite@1.0.30001799: {}
chai@6.2.2: {} chai@6.2.2: {}
@ -2401,6 +2806,13 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 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: {} csstype@3.2.3: {}
d3-color@3.1.0: {} d3-color@3.1.0: {}
@ -2444,18 +2856,37 @@ snapshots:
graphlib: 2.1.8 graphlib: 2.1.8
lodash: 4.18.1 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: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js@10.6.0: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
electron-to-chromium@1.5.372: {} electron-to-chromium@1.5.372: {}
entities@7.0.1: {}
entities@8.0.0: {}
es-module-lexer@2.1.0: {} es-module-lexer@2.1.0: {}
escalade@3.2.0: {} escalade@3.2.0: {}
@ -2602,28 +3033,77 @@ snapshots:
dependencies: dependencies:
lodash: 4.18.1 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-estree@0.25.1: {}
hermes-parser@0.25.1: hermes-parser@0.25.1:
dependencies: dependencies:
hermes-estree: 0.25.1 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@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
indent-string@4.0.0: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-glob@4.0.3: is-glob@4.0.3:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-potential-custom-element-name@1.0.1: {}
isexe@2.0.0: {} isexe@2.0.0: {}
js-tokens@4.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: {} jsesc@3.1.0: {}
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
@ -2698,14 +3178,22 @@ snapshots:
lodash@4.18.1: {} lodash@4.18.1: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
mdn-data@2.27.1: {}
min-indent@1.0.1: {}
minimatch@10.2.5: minimatch@10.2.5:
dependencies: dependencies:
brace-expansion: 5.0.6 brace-expansion: 5.0.6
@ -2743,6 +3231,10 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
parse5@8.0.1:
dependencies:
entities: 8.0.0
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@ -2769,6 +3261,12 @@ snapshots:
prelude-ls@1.2.1: {} 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: {} punycode@2.3.1: {}
react-dom@19.2.7(react@19.2.7): react-dom@19.2.7(react@19.2.7):
@ -2776,6 +3274,8 @@ snapshots:
react: 19.2.7 react: 19.2.7
scheduler: 0.27.0 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): react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
@ -2819,6 +3319,13 @@ snapshots:
- '@types/react' - '@types/react'
- immer - 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: rolldown@1.0.3:
dependencies: dependencies:
'@oxc-project/types': 0.133.0 '@oxc-project/types': 0.133.0
@ -2840,6 +3347,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-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: {} scheduler@0.27.0: {}
semver@6.3.1: {} semver@6.3.1: {}
@ -2860,6 +3371,12 @@ snapshots:
std-env@4.1.0: {} 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: {} tinybench@2.9.0: {}
tinyexec@1.2.4: {} tinyexec@1.2.4: {}
@ -2871,6 +3388,20 @@ snapshots:
tinyrainbow@3.1.0: {} 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): ts-api-utils@2.5.0(typescript@6.0.3):
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
@ -2896,6 +3427,8 @@ snapshots:
undici-types@7.18.2: {} undici-types@7.18.2: {}
undici@7.27.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.2): update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
@ -2936,7 +3469,7 @@ snapshots:
'@types/node': 24.13.2 '@types/node': 24.13.2
fsevents: 2.3.3 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: dependencies:
'@vitest/expect': 4.1.8 '@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@24.13.2)) '@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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 24.13.2 '@types/node': 24.13.2
happy-dom: 20.10.3
jsdom: 29.1.1
transitivePeerDependencies: transitivePeerDependencies:
- msw - 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: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
@ -2974,6 +3527,12 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
ws@8.21.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yallist@3.1.1: {} yallist@3.1.1: {}
yocto-queue@0.1.0: {} 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", "#1a2740", "#243453",
"#4a5b80", "#7a8aa8", "#4a5b80", "#7a8aa8",
"#c46a14", "#3d6a2c", "#a6342a", "#1d6f82", "#c46a14", "#3d6a2c", "#a6342a", "#1d6f82",
"#0c1322", "#e6edf7",
]); ]);
function fail(msg) { console.log("✗", msg); process.exitCode = 1; } 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 Landing from "./scenes/Landing";
import MissionControl from "./scenes/MissionControl"; import MissionControl from "./scenes/MissionControl";
import RunHistory from "./scenes/RunHistory"; import RunHistory from "./scenes/RunHistory";
import Studio from "./scenes/Studio"; import Wizard from "./scenes/Wizard";
import Settings from "./scenes/Settings"; import Settings from "./scenes/Settings";
import Login from "./scenes/Login";
import SsoCallback from "./scenes/SsoCallback";
import CommandBar from "./components/CommandBar"; import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import Console from "./components/Console"; import Console from "./components/Console";
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"; import { liveMeta } from "./data/scenarios";
export default function App() { export default function App() {
@ -30,6 +32,16 @@ export default function App() {
const userEmail = useApp((s) => s.userEmail); const userEmail = useApp((s) => s.userEmail);
const startPolling = useApp((s) => s.startPolling); const startPolling = useApp((s) => s.startPolling);
const stopPolling = useApp((s) => s.stopPolling); 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(() => { useEffect(() => {
if (mode === "live") startPolling(); if (mode === "live") startPolling();
@ -40,7 +52,7 @@ export default function App() {
return ( return (
<div className={`shell shell-${scene}`}> <div className={`shell shell-${scene}`}>
{scene !== "landing" && ( {scene !== "landing" && scene !== "login" && scene !== "sso-callback" && (
<header className="topbar" data-anchor="topbar"> <header className="topbar" data-anchor="topbar">
<button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home"> <button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home">
<span className="brand-mark sm" /> <span className="brand-mark sm" />
@ -121,6 +133,9 @@ export default function App() {
<Layers size={12} /> Console <Layers size={12} /> Console
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>} {apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
</button> </button>
<button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme">
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
</button>
<button className="link-btn" onClick={() => setCmdOpen(true)}> <button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> <kbd>K</kbd> <Cmd size={13} /> <kbd>K</kbd>
</button> </button>
@ -129,10 +144,12 @@ export default function App() {
)} )}
<div className="scene"> <div className="scene">
{scene === "login" && <Login />}
{scene === "sso-callback" && <SsoCallback />}
{scene === "landing" && <Landing />} {scene === "landing" && <Landing />}
{scene === "mission" && <MissionControl />} {scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />} {scene === "history" && <RunHistory />}
{scene === "studio" && <Studio />} {scene === "studio" && <Wizard />}
{scene === "settings" && <Settings />} {scene === "settings" && <Settings />}
</div> </div>

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Command } from "cmdk"; import { Command } from "cmdk";
import { useApp, scenarioById } from "../state/store"; 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() { export default function CommandBar() {
const open = useApp((s) => s.cmdOpen); const open = useApp((s) => s.cmdOpen);
@ -80,7 +80,7 @@ export default function CommandBar() {
<HistoryIcon size={13} /> Run History <HistoryIcon size={13} /> Run History
</Command.Item> </Command.Item>
<Command.Item onSelect={() => { setScene("studio"); close(); }}> <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> <span className="cmd-hint">design & publish a new process</span>
</Command.Item> </Command.Item>
<Command.Item onSelect={() => { setScene("settings"); close(); }}> <Command.Item onSelect={() => { setScene("settings"); close(); }}>
@ -120,7 +120,7 @@ export default function CommandBar() {
onSelect={async () => { onSelect={async () => {
close(); close();
if (mode !== "live") { pushToast("warn", "Switch to LIVE mode to start a real instance."); return; } 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}" <Play size={13} /> Start new instance of "{sc.defName}"

View File

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

View File

@ -133,7 +133,7 @@ function StartInstanceButton({ defKey, live }: { defKey: string; live: boolean }
} }
setBusy(true); setBusy(true);
try { try {
await startInstance(defKey, `MC demo · ${new Date().toLocaleString()}`); await startInstance(defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
} finally { setBusy(false); } } finally { setBusy(false); }
}; };
return ( 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 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 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 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 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 ArrowDown = (p: P) => <Svg {...p}><path d="M12 5v14M5 12l7 7 7-7" /></Svg>;
export const Sparkles = (p: P) => <Svg {...p}><path d="M12 3l1.7 4.3L18 9l-4.3 1.7L12 15l-1.7-4.3L6 9l4.3-1.7zM19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9z" /></Svg>; export const 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 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 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 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 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: "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: "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: "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: "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." }, { 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 - light paper canvas + navy frame + amber accent
- 1px rules, square edges, monospace operational density - 1px rules, square edges, monospace operational density
===================================================================== */ ===================================================================== */
[data-theme="dark"] {
--bp-paper: #0c1322;
--bp-paper-2: #1a2740;
--bp-paper-3: #243453;
--bp-navy: #e6edf7;
--bp-navy-2: #d5dde9;
--bp-muted: #7a8aa8;
--bp-muted-2: #4a5b80;
}
:root { :root {
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */ /* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
--bp-paper: #f5f7fb; --bp-paper: #f5f7fb;
@ -108,6 +119,8 @@ kbd {
Topbar (industrial instrument strip) Topbar (industrial instrument strip)
===================================================================== */ ===================================================================== */
.topbar { .topbar {
position: relative;
z-index: 190;
display: grid; display: grid;
grid-template-columns: auto auto 1fr auto; grid-template-columns: auto auto 1fr auto;
align-items: stretch; align-items: stretch;
@ -1143,6 +1156,198 @@ select.studio-input { background: var(--bp-paper); }
.toast-warn { border-left-color: var(--bp-amber); } .toast-warn { border-left-color: var(--bp-amber); }
.toast-err { border-left-color: var(--bp-err); } .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 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.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__edge:focus .react-flow__edge-path { stroke: var(--bp-amber); }
[data-canvas="blueprint"] .react-flow__attribution { display: none; } [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 = { export const api = {
config: DEFAULT_CONFIG, 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. */ /** Subscribe to all API calls. Returns unsubscribe. */
onCall(fn: ApiCallObserver): () => void { onCall(fn: ApiCallObserver): () => void {
observers.add(fn); observers.add(fn);
@ -184,6 +191,38 @@ export const api = {
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal); 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[]> { async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal); const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal);
return body.items ?? []; return body.items ?? [];
@ -256,7 +295,7 @@ export const api = {
{ {
kind: "definition", kind: "definition",
status: "published", status: "published",
source_context: "mc-demo-studio", source_context: "mission-control-studio",
version: 1, version: 1,
...payload, ...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> </h1>
<p className="hero-sub"> <p className="hero-sub">
FlowMaster turns the operational map of a company into living, FlowMaster turns the operational map of a company into living,
typed processes backed by humans, agents, and rules and gives you a typed processes backed by humans, agents, and rules and gives you
single command-center to drive them. Procurement scenarios are real, one control surface to drive them. Every procurement, finance, people,
backed by EA2 on{" "} and service workflow runs end-to-end through EA2 on{" "}
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>; <span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "the FlowMaster backend"}</span>.
AR, HCM, GL, and Service are industry blueprints showing how this
same shell extends to any process family.
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
<button className="btn btn-primary btn-lg" onClick={() => setScene("mission")}> <button className="btn btn-primary btn-lg" onClick={() => setScene("mission")}>
@ -129,7 +127,7 @@ export default function Landing() {
</main> </main>
<footer className="landing-foot"> <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> </footer>
</div> </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. // RunHistory scene: cross-scenario timeline.
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useApp } from "../state/store"; 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; const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
type Status = (typeof STATUS_FILTERS)[number]; type Status = (typeof STATUS_FILTERS)[number];
@ -36,7 +36,7 @@ export default function RunHistory() {
<div className="rh"> <div className="rh">
<header className="rh-head"> <header className="rh-head">
<div> <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> <h2 className="mc-hero-title">All scenarios · all runs</h2>
</div> </div>
<nav className="rh-filters"> <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 { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types"; import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings"; export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
export type DataMode = "snapshot" | "live"; export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";
@ -82,15 +82,18 @@ interface AppState {
actor: Actor | null; actor: Actor | null;
userEmail: string; userEmail: string;
userDisplayName: string | null; userDisplayName: string | null;
isAuthed: boolean;
setUserEmail: (e: string) => void; 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; pollEverySec: number;
setPollEverySec: (n: number) => void; setPollEverySec: (n: number) => void;
pollTimer: number | null; pollTimer: number | null;
startPolling: () => void; startPolling: () => void;
stopPolling: () => void; stopPolling: () => void;
pollLiveTick: () => Promise<void>;
/** API call log */ /** API call log */
consoleOpen: boolean; consoleOpen: boolean;
@ -240,28 +243,32 @@ export const useApp = create<AppState>((set, get) => {
actor: null, actor: null,
userEmail: prefs.email ?? "dev@flow-master.ai", userEmail: prefs.email ?? "dev@flow-master.ai",
userDisplayName: null, userDisplayName: null,
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
setUserEmail: (e) => { set({ userEmail: e }); persist(); }, setUserEmail: (e) => { set({ userEmail: e }); persist(); },
loginAs: async (email) => { loginAs: async (email, password) => {
api.clearToken(); api.clearToken();
api.config = { ...api.config, email }; api.config = { ...api.config, email };
set({ userEmail: email }); set({ userEmail: email });
persist(); persist();
try { try {
await api.signIn(email, password);
const me = await api.me(); const me = await api.me();
set({ set({
actor: { mode: "direct_user", user_id: me.user_id }, actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null, userDisplayName: me.display_name ?? null,
isAuthed: true,
}); });
get().pushToast("ok", `Signed in as ${me.email}`); get().pushToast("ok", `Signed in as ${me.email}`);
if (get().mode === "live") await get().refreshLive(); if (get().mode === "live") await get().refreshLive();
} catch (e) { } catch (e) {
get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`); get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`);
throw e;
} }
}, },
pollEverySec: prefs.pollEverySec ?? 8, pollEverySec: prefs.pollEverySec ?? 30,
setPollEverySec: (n) => { setPollEverySec: (n) => {
set({ pollEverySec: Math.max(2, Math.min(120, n)) }); set({ pollEverySec: Math.max(5, Math.min(300, n)) });
persist(); persist();
if (get().pollTimer) { get().stopPolling(); get().startPolling(); } if (get().pollTimer) { get().stopPolling(); get().startPolling(); }
}, },
@ -273,7 +280,7 @@ export const useApp = create<AppState>((set, get) => {
const id = window.setInterval(() => { const id = window.setInterval(() => {
const s = get(); const s = get();
if (s.mode !== "live" || s.liveLoading) return; if (s.mode !== "live" || s.liveLoading) return;
void s.refreshLive(); void s.pollLiveTick();
}, get().pollEverySec * 1000); }, get().pollEverySec * 1000);
set({ pollTimer: id }); set({ pollTimer: id });
}, },
@ -283,6 +290,39 @@ export const useApp = create<AppState>((set, get) => {
if (id) window.clearInterval(id); if (id) window.clearInterval(id);
set({ pollTimer: null }); 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, consoleOpen: prefs.consoleOpen ?? false,
setConsoleOpen: (v) => { set({ consoleOpen: v }); persist(); }, 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';