Compare commits
135 Commits
nathaniel/
...
drjkl/inpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fc3fa340 | ||
|
|
2526d1cd58 | ||
|
|
5d7032cb76 | ||
|
|
7697348c2f | ||
|
|
fc6819210c | ||
|
|
34a71e80c2 | ||
|
|
3772615fcb | ||
|
|
75ad306a80 | ||
|
|
78469817a7 | ||
|
|
f4ceedb787 | ||
|
|
b4fa1ed3f1 | ||
|
|
a4f38f71e2 | ||
|
|
603154e24f | ||
|
|
5652a1c23f | ||
|
|
b71340970e | ||
|
|
32b3c28a1c | ||
|
|
1b9906efbb | ||
|
|
9f3d11a0ba | ||
|
|
e6ab296c94 | ||
|
|
0679deb5f9 | ||
|
|
7400d4e6f2 | ||
|
|
1940be51d7 | ||
|
|
a71df2f1b5 | ||
|
|
a310989209 | ||
|
|
84693005e4 | ||
|
|
51ba284f7f | ||
|
|
4c9a2b1312 | ||
|
|
7e08de9e3b | ||
|
|
dce951b819 | ||
|
|
0d0a3dbdec | ||
|
|
cb9ebecee8 | ||
|
|
8c43b39e7b | ||
|
|
070ea46873 | ||
|
|
e09b47ffe5 | ||
|
|
c00b778059 | ||
|
|
64bce0e602 | ||
|
|
a6b9d4e254 | ||
|
|
dd3dd63b25 | ||
|
|
c306c567f1 | ||
|
|
2185f61b51 | ||
|
|
29c7781d0a | ||
|
|
d07248890c | ||
|
|
f5c5d10897 | ||
|
|
0b959753ee | ||
|
|
2d51f2ca43 | ||
|
|
45d0566ff9 | ||
|
|
62dedcc0dd | ||
|
|
01c0c8e86d | ||
|
|
fcba5b297c | ||
|
|
bdf47aebca | ||
|
|
43a4058be9 | ||
|
|
18c9e769a4 | ||
|
|
567643abe3 | ||
|
|
75feebdf5e | ||
|
|
d14ed83e1e | ||
|
|
cb3e757497 | ||
|
|
56f2abc519 | ||
|
|
1fb64cec49 | ||
|
|
1038264ae3 | ||
|
|
fc55165eae | ||
|
|
da9280d13c | ||
|
|
056531b59a | ||
|
|
b54a1a3471 | ||
|
|
31ef2d0dfe | ||
|
|
1d8e6e2b9b | ||
|
|
327e3f84f0 | ||
|
|
cf8f87c37f | ||
|
|
af77a27567 | ||
|
|
8978502e8d | ||
|
|
394ff10b23 | ||
|
|
53dde242bd | ||
|
|
310de8ee08 | ||
|
|
d4ad799f0e | ||
|
|
2f1e14c53f | ||
|
|
495ea990f3 | ||
|
|
c8692de343 | ||
|
|
1597d20bed | ||
|
|
e38dca7f5f | ||
|
|
02790eaa28 | ||
|
|
068eb13ee0 | ||
|
|
8d17098926 | ||
|
|
ec7dbde89c | ||
|
|
0175ab9aa2 | ||
|
|
0c073cac53 | ||
|
|
af8812e171 | ||
|
|
b8e8b5b53a | ||
|
|
003dcea90a | ||
|
|
0fb8625abf | ||
|
|
510dd01e8b | ||
|
|
b8d319b187 | ||
|
|
a505780953 | ||
|
|
ebb0f25231 | ||
|
|
4fad4889fa | ||
|
|
68255992ec | ||
|
|
b2c479479e | ||
|
|
b49eac4753 | ||
|
|
4ddf08240d | ||
|
|
d831b1de54 | ||
|
|
4fb92e0bf6 | ||
|
|
20fe604ff9 | ||
|
|
a99fe3a1df | ||
|
|
b6ebd544c7 | ||
|
|
ee0f984c21 | ||
|
|
43b4e50697 | ||
|
|
c6e67b38b7 | ||
|
|
5b065a299e | ||
|
|
97298116ee | ||
|
|
a4eab62f35 | ||
|
|
4dac3ccb22 | ||
|
|
66bad3b522 | ||
|
|
f1b98c63bd | ||
|
|
f717873025 | ||
|
|
aa3d192636 | ||
|
|
5b98697bcf | ||
|
|
2e4c9c6fdc | ||
|
|
7d2858e74b | ||
|
|
b9a0ac0fed | ||
|
|
b61e54db3b | ||
|
|
b51ea29074 | ||
|
|
d85ce2bf9e | ||
|
|
fa87c46f90 | ||
|
|
941f151520 | ||
|
|
df5f5b3367 | ||
|
|
156f2f59b7 | ||
|
|
d855466fdf | ||
|
|
9d5719871a | ||
|
|
7610a61250 | ||
|
|
47c8b09ebf | ||
|
|
65b4c53bcb | ||
|
|
15b31d69ea | ||
|
|
471236e08d | ||
|
|
4cc0402325 | ||
|
|
a2adfe5124 | ||
|
|
49a90d4e2e | ||
|
|
d6c582c399 |
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,6 +134,27 @@ jobs:
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Syft telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Syft references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)syft' \
|
||||
-e '(?i)sy-d\.io' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Syft references found in dist assets!'
|
||||
echo 'Syft must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Syft references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--ignore-errors source,unmapped \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,6 +95,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,6 +55,3 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,7 +67,15 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: always() && !cancelled()
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,12 +32,13 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
|
||||
@@ -38,7 +38,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp },
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,36 +33,41 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
label: t('cta.getStarted', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
href: routes.mcp
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@@ -82,6 +82,7 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
|
||||
@@ -26,6 +26,10 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2183,6 +2187,7 @@ const translations = {
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
@@ -2196,16 +2201,16 @@ const translations = {
|
||||
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
|
||||
// so the copy can be swapped without renaming the key.
|
||||
'nav.featuredProductsTitle': {
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
en: 'NEW: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"pos": [10, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
@@ -38,7 +38,7 @@
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "outer-subgraph-with-promoted-missing-model",
|
||||
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
@@ -71,7 +71,7 @@
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "inner-subgraph-with-promoted-missing-model",
|
||||
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
@@ -105,7 +105,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inner-subgraph-with-promoted-missing-model",
|
||||
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
|
||||
@@ -537,7 +537,6 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
const ROUTE_PATTERN_WORKFLOW_TEMPLATES = /\/api\/workflow_templates(?:\?.*)?$/
|
||||
const ROUTE_PATTERN_TEMPLATE_INDEX = /\/templates\/index\.json(?:\?.*)?$/
|
||||
const ROUTE_PATTERN_TEMPLATE_THUMBNAILS = /\/templates\/.*\.webp(?:\?.*)?$/
|
||||
|
||||
interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
@@ -41,10 +45,6 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
@@ -64,29 +64,30 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockCustomTemplates()
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
async mockCustomTemplates(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(customTemplates),
|
||||
body: '{}',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
await this.page.route(
|
||||
ROUTE_PATTERN_WORKFLOW_TEMPLATES,
|
||||
customTemplatesHandler
|
||||
)
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
@@ -98,9 +99,8 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
@@ -114,12 +114,8 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
@@ -129,15 +125,6 @@ export class TemplateHelper {
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
|
||||
@@ -7,10 +7,6 @@ export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
await use(createTemplateHelper(page))
|
||||
}
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
@@ -48,25 +48,6 @@ test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-badge-multiple.png')
|
||||
})
|
||||
|
||||
test('Can add badge left-side', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const LGraphBadge = window.LGraphBadge!
|
||||
const app = window.app as ComfyApp
|
||||
const graph = app.graph
|
||||
const nodes = graph.nodes
|
||||
|
||||
for (const node of nodes) {
|
||||
node.badges = [new LGraphBadge({ text: 'Test Badge' })]
|
||||
// @ts-expect-error - Enum value
|
||||
node.badgePosition = 'top-left'
|
||||
}
|
||||
|
||||
graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-badge-left.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 93 KiB |
@@ -691,7 +691,8 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -17,7 +17,7 @@ test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -25,8 +25,6 @@ test.describe(
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await templateApi.mockThumbnails()
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
@@ -56,7 +54,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
await templateApi.mock()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -101,7 +99,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
await templateApi.mock()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -143,7 +141,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
await templateApi.mock()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -184,7 +182,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
await templateApi.mock()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -222,7 +220,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mockIndex()
|
||||
await templateApi.mock()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1238,7 +1238,7 @@ test(
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
@@ -1251,7 +1251,7 @@ test(
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
|
||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 89 KiB |
@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
): Promise<{ x: number; y: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return box
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
test('should allow moving nodes by dragging', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
|
||||
steps: 5
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
@@ -8,25 +8,32 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
let legacyNodeId = toNodeId(10)
|
||||
|
||||
await test.step('setup', async () => {
|
||||
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
|
||||
const legacyNode = await comfyPage.nodeOps.addNode(
|
||||
'DevToolsNodeWithLegacyWidget',
|
||||
undefined,
|
||||
{
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
)
|
||||
legacyNodeId = legacyNode.id
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
[String(legacyNodeId), 'legacy_widget']
|
||||
])
|
||||
})
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
|
||||
toNodeId(10)
|
||||
)
|
||||
const getWidth = async () =>
|
||||
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
|
||||
?.width ?? 0
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
|
||||
const { width, height } = (await legacyWidget.boundingBox())!
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
|
||||
const legacyWidgetRef = await nodeRef.getWidget(0)
|
||||
expect(await legacyWidgetRef.getValue()).toBe(0)
|
||||
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
|
||||
@@ -36,8 +43,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
})
|
||||
|
||||
await test.step('Resize to update width', async () => {
|
||||
await expect.poll(getWidth).toBeGreaterThan(0)
|
||||
const initialWidth = await getWidth()
|
||||
expect(initialWidth).toBeGreaterThan(0)
|
||||
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
|
||||
@@ -3,31 +3,43 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
test('Should display added widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
const nodeId = toNodeId(
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(node) => (node.widgets?.length ?? 0) === 1
|
||||
)
|
||||
if (!node) throw new Error('Node with one widget not found')
|
||||
return String(node.id)
|
||||
})
|
||||
)
|
||||
await expect(loadCheckpointNode).toHaveCount(1)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
|
||||
const widgets = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-widget')
|
||||
|
||||
await expect(widgets).toHaveCount(1)
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) throw new Error(`Node ${nodeId} not found`)
|
||||
node.addWidget('text', 'extra_widget_a', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
}, nodeId)
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) throw new Error(`Node ${nodeId} not found`)
|
||||
node.addWidget('text', 'extra_widget_b', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
}, nodeId)
|
||||
await expect(widgets).toHaveCount(3)
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) throw new Error(`Node ${nodeId} not found`)
|
||||
node.addWidget('text', 'extra_widget_c', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
}, nodeId)
|
||||
await expect(widgets).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('Should hide removed widgets', async ({ comfyPage }) => {
|
||||
|
||||
@@ -5,12 +5,7 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Add a PreviewAny node which has a read-only textarea with a copy button
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('PreviewAny')
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,22 @@ text below says "the World," read "the set of dedicated stores"; where it shows
|
||||
`world.getComponent(id, Component)`, read the matching store getter (for
|
||||
example `widgetValueStore.getWidget(widgetId)`).
|
||||
|
||||
### Amendment (2026-07-05, PRs 13436/13449)
|
||||
|
||||
Two stores joined the dedicated-store set: `linkStore` (link topology,
|
||||
keyed by target input slot in root-graph-scoped buckets — see
|
||||
[Link Topology Store](../architecture/link-topology-store.md)) and
|
||||
`rerouteStore` (reroute chain state with link membership derived from
|
||||
the links' `parentId` chains — see
|
||||
[Reroute Chain Store](../architecture/reroute-chain-store.md)). Both
|
||||
follow the proxy-returning registration pattern established by
|
||||
`BaseWidget`/`widgetValueStore`: the store bucket is a `reactive(Map)`,
|
||||
registration inserts the class's state object by reference and the class
|
||||
adopts the reactive proxy read back from the bucket, so class field
|
||||
writes are tracked without an action chokepoint. The `layoutStore` link
|
||||
connectivity mirror and the `slot._floatingLinks` sets were deleted in
|
||||
the same work; the layout store now holds geometry only.
|
||||
|
||||
## Context
|
||||
|
||||
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
|
||||
@@ -115,6 +131,15 @@ Components are plain data objects — no methods, no back-references to parent e
|
||||
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
|
||||
| `LinkState` | `_dragging`, `data` |
|
||||
|
||||
> **Amended (2026-07-05):** `LinkEndpoints` shipped as
|
||||
> `LinkTopology { id, originNodeId, originSlot, targetNodeId,
|
||||
targetSlot, type, parentId? }` in a dedicated `linkStore`, keyed by
|
||||
> **target input slot** (not link id) in root-graph-scoped buckets, with
|
||||
> floating and subgraph-output links in an unkeyed side set. `LLink`
|
||||
> reads through the store's reactive proxy (`_state`). See
|
||||
> [Link Topology Store](../architecture/link-topology-store.md).
|
||||
> `LinkVisual` and `LinkState` remain unextracted.
|
||||
|
||||
#### Subgraph (Node Components)
|
||||
|
||||
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
||||
@@ -140,6 +165,13 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
|
||||
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
|
||||
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
|
||||
|
||||
> **Amended (2026-07-05):** the input side of `SlotConnection` is
|
||||
> subsumed by the `linkStore` key — the input-slot→link mapping _is_ the
|
||||
> store's primary index (`isInputSlotConnected` / `getInputSlotLink`).
|
||||
> The `slot._floatingLinks` sets were deleted; floating-link attachment
|
||||
> is derived from the links' own endpoints (`slotFloatingLinks`). The
|
||||
> `input.link` / `output.links` class mirrors remain un-migrated.
|
||||
|
||||
#### Reroute
|
||||
|
||||
| Component | Data (from `Reroute`) |
|
||||
@@ -148,6 +180,13 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
|
||||
| `RerouteLinks` | `parentId`, input/output link IDs |
|
||||
| `RerouteVisual` | `color`, badge config |
|
||||
|
||||
> **Amended (2026-07-04):** `RerouteLinks` was superseded during design
|
||||
> review. The stored component is chain state only —
|
||||
> `RerouteChain { parentId, floating? }` — and link membership
|
||||
> (`linkIds` / `floatingLinkIds`) is derived from the links' `parentId`
|
||||
> chains rather than stored. See
|
||||
> [Reroute Chain Store](../architecture/reroute-chain-store.md).
|
||||
|
||||
#### Group
|
||||
|
||||
| Component | Data (from `LGraphGroup`) |
|
||||
@@ -271,6 +310,9 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Link Topology Store](../architecture/link-topology-store.md) | Design record for the `linkStore` — target-input-slot keying, root-scoped buckets, registration protocol |
|
||||
| [Reroute Chain Store](../architecture/reroute-chain-store.md) | Design record for the `rerouteStore` — chain state, derived link membership, load-time id dedup |
|
||||
| [Domain Glossary](../architecture/domain-glossary.md) | Canonical vocabulary for links, reroutes, chains, and membership |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — patterns adopted, departed, when to revisit |
|
||||
|
||||
50
docs/architecture/domain-glossary.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Domain Glossary
|
||||
|
||||
Canonical vocabulary for the graph domain. Terms are added as they are
|
||||
resolved during design work; keep entries implementation-free. Intended
|
||||
to grow into a proper reference document.
|
||||
|
||||
Design records that rely on this vocabulary:
|
||||
[Link Topology Store](link-topology-store.md),
|
||||
[Reroute Chain Store](reroute-chain-store.md),
|
||||
[Node Badge Store](node-badge-store.md),
|
||||
[ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## Badges
|
||||
|
||||
- **Badge** — a small visual annotation rendered on a node: its numeric
|
||||
id, lifecycle state, source pack, execution price, or an
|
||||
extension-provided marker. Badges are presentation state; they never
|
||||
affect execution and are never persisted with the workflow.
|
||||
- **Badge kind** — the category a badge belongs to: **core** (identity /
|
||||
lifecycle / source, projected from the node's definition and user
|
||||
settings), **credits** (price of executing an API node, including
|
||||
aggregated prices of nodes inside a subgraph), or **extension**
|
||||
(provided by third-party code).
|
||||
- **Badge source** — the domain state a badge's content is computed
|
||||
from (settings, node definition, palette, pricing, widget values,
|
||||
input connectivity). A badge is always a projection of its sources;
|
||||
it is never authored directly by a user.
|
||||
|
||||
## Links & Reroutes
|
||||
|
||||
- **Link** — a directed data connection from one node's output slot to
|
||||
another node's input slot. At most one live link targets a given input
|
||||
slot.
|
||||
- **Floating link** — a link with exactly one attached endpoint, kept
|
||||
alive so a reroute chain survives disconnection. The unattached end is
|
||||
unassigned.
|
||||
- **Reroute** — a visual waypoint that a link's rendered path travels
|
||||
through. Purely organisational; never affects data flow. A reroute's
|
||||
identity is unique within a workflow, subgraphs included.
|
||||
- **Reroute chain** — the ordered sequence of reroutes a link passes
|
||||
through, from the node output toward the input. Each reroute names its
|
||||
upstream neighbour via _parent_; the link names the chain's most
|
||||
downstream reroute (the **terminal reroute**).
|
||||
- **Link membership (of a reroute)** — the set of links whose chains pass
|
||||
through that reroute. Membership is _defined by_ the chains: a link is a
|
||||
member of exactly the reroutes on the chain walked from its terminal
|
||||
reroute upstream. It is never authored independently of the chain.
|
||||
- **Floating slot marker** — the annotation on the last reroute of a
|
||||
floating chain recording which side (input or output) the chain still
|
||||
faces.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
|
||||
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Link topology and reroute chain state shipped later into `linkStore` (PR 13436, keyed by target input slot in root-graph-scoped buckets — see [link-topology-store.md](link-topology-store.md)) and `rerouteStore` (PR 13449, membership derived from the links' `parentId` chains — see [reroute-chain-store.md](reroute-chain-store.md)); `layoutStore` keeps link/reroute geometry only. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
|
||||
|
||||
@@ -66,6 +66,7 @@ sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant LM as useLayoutMutations()
|
||||
participant LKS as linkStore
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant NOS as nodeOutputStore
|
||||
@@ -73,12 +74,14 @@ sequenceDiagram
|
||||
|
||||
Caller->>CS: removeNode(nodeId)
|
||||
|
||||
CS->>LS: read node links (incoming + outgoing)
|
||||
LS-->>CS: linkIds
|
||||
CS->>LKS: read node links (incoming + outgoing)
|
||||
LKS-->>CS: link topologies
|
||||
|
||||
loop each linkId
|
||||
CS->>LM: deleteLink(linkId)
|
||||
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
|
||||
loop each link
|
||||
CS->>LKS: unregister link topology
|
||||
Note over CS,LKS: via the LGraph._removeLink chokepoint —<br/>map delete + store unregistration
|
||||
CS->>LS: drop link geometry
|
||||
Note over LKS,LS: linkStore owns topology;<br/>layoutStore only drops geometry
|
||||
end
|
||||
|
||||
loop each widget on node
|
||||
@@ -93,15 +96,15 @@ sequenceDiagram
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
|
||||
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates linkStore + layout/widget/output stores |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | linkStore unregistration per link (via `_removeLink`) + geometry drop per layout entry |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
|
||||
| Store cleanup | WidgetValueStore not cleaned up; link geometry still removed from LayoutStore | Coordinated: `deleteWidget`, linkStore unregister + `deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
|
||||
|
||||
## 2. Serialization
|
||||
|
||||
@@ -223,7 +226,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
opt has subgraph definitions
|
||||
G->>G: deduplicateSubgraphNodeIds()
|
||||
G->>G: deduplicateSubgraphNodeIds() + deduplicateSubgraphRerouteIds()
|
||||
loop each subgraph (topological order)
|
||||
G->>G: createSubgraph(data)
|
||||
end
|
||||
@@ -250,7 +253,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
G->>G: add floating links
|
||||
G->>G: validate reroutes
|
||||
G->>G: prune reroutes with derived totalLinks === 0
|
||||
G->>G: _removeDuplicateLinks()
|
||||
|
||||
loop each serialized group
|
||||
@@ -262,6 +265,8 @@ sequenceDiagram
|
||||
|
||||
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
|
||||
|
||||
Note on load-time id hygiene: root `configure()` deduplicates **node ids** and **reroute ids** across sibling subgraph definitions before configuring them (`deduplicateSubgraphNodeIds` / `deduplicateSubgraphRerouteIds`), because both share root-graph-scoped store buckets. Link-id dedup is deliberately absent — the linkStore key is the target input slot, not the link id, so duplicate link ids across definitions cannot collide. Orphaned reroutes are pruned by the derived `totalLinks === 0` check; the old `validateLinks` set-repair is gone (membership is derived, so there is no stored set to drift).
|
||||
|
||||
### ECS Flow
|
||||
|
||||
```mermaid
|
||||
@@ -532,7 +537,7 @@ sequenceDiagram
|
||||
participant G as LGraph
|
||||
participant L as LLink
|
||||
participant R as Reroute
|
||||
participant LS as LayoutStore
|
||||
participant LKS as linkStore
|
||||
|
||||
Caller->>N1: connectSlots(output, targetNode, input)
|
||||
|
||||
@@ -545,24 +550,24 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
N1->>L: new LLink(++lastLinkId, type, ...)
|
||||
N1->>G: _links.set(link.id, link)
|
||||
N1->>LS: layoutMutations.createLink()
|
||||
N1->>G: graph._addLink(link)
|
||||
G->>LKS: register topology (keyed by target input slot)
|
||||
Note over G,LKS: chokepoint: _links.set + linkStore registration
|
||||
|
||||
N1->>N1: output.links.push(link.id)
|
||||
N1->>N2: input.link = link.id
|
||||
|
||||
loop each reroute in path
|
||||
N1->>R: reroute.linkIds.add(link.id)
|
||||
end
|
||||
N1->>R: anchorRerouteChain(graph, link)
|
||||
Note over N1,R: clears floating markers on the chain —<br/>membership is derived from link.parentId,<br/>no per-reroute linkIds writes
|
||||
|
||||
N1->>G: _version++
|
||||
N1->>G: incrementVersion()
|
||||
N1->>N1: onConnectionsChange?(OUTPUT, ...)
|
||||
N1->>N2: onConnectionsChange?(INPUT, ...)
|
||||
N1->>G: setDirtyCanvas()
|
||||
N1->>G: afterChange()
|
||||
```
|
||||
|
||||
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
|
||||
Problems: the source node orchestrates everything — it reaches into the graph's link map (via the `_addLink` chokepoint), the target node's slot, and the version counter. Reroute membership no longer needs writes (derived via rerouteStore), but the slot mirrors (`output.links`, `input.link`) are still mutated by hand.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
@@ -570,29 +575,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant LKS as linkStore
|
||||
|
||||
Caller->>CS: connect(outputSlot, inputSlot)
|
||||
|
||||
CS->>LS: read input slot link
|
||||
CS->>LKS: getInputSlotLink(graphId, targetNodeId, targetSlot)
|
||||
opt already connected
|
||||
CS->>LM: deleteLink(existingLinkId)
|
||||
CS->>LKS: unregister existing topology
|
||||
end
|
||||
|
||||
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
|
||||
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
|
||||
CS->>LKS: register LinkTopology {<br/> id, originNodeId, originSlot,<br/> targetNodeId, targetSlot, type<br/>}
|
||||
Note over CS,LKS: the target-slot key IS the input-side<br/>slot connection — no separate endpoint update.<br/>Reroute membership derives from parentId;<br/>rerouteStore needs no write
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
|
||||
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
|
||||
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
|
||||
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Orchestrator | Source node (reaches into graph, target slots) | ConnectivitySystem (reads linkStore) |
|
||||
| Side effects | `incrementVersion()`, `setDirtyCanvas()`, `afterChange()`, callbacks | topology registration — endpoints + change tracking included |
|
||||
| Reroute handling | None needed — membership derived from `parentId` chains (rerouteStore); `anchorRerouteChain` only clears floating markers | Same — derived membership is already the target shape |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Input side subsumed by the linkStore key; output side pending SlotConnection extraction |
|
||||
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
|
||||
|
||||
## 7. Copy / Paste
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ For verified accuracy of these documents, see
|
||||
|
||||
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
|
||||
> string IDs, one store per concern (widget values, DOM widgets, layout, node
|
||||
> outputs, subgraph navigation, preview exposure). The earlier "single unified
|
||||
> outputs, subgraph navigation, preview exposure, link topology, reroute
|
||||
> chains). The earlier "single unified
|
||||
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
|
||||
> was rejected. PR 12617 shipped the first stores against composite
|
||||
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
|
||||
@@ -113,14 +114,25 @@ narrow accessor surface. There is no single container that fronts all entities.
|
||||
|
||||
Shipped stores:
|
||||
|
||||
| Store | File |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
|
||||
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
|
||||
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
|
||||
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
|
||||
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
|
||||
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
|
||||
| Store | File |
|
||||
| -------------------------- | ----------------------------------------------- |
|
||||
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
|
||||
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
|
||||
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
|
||||
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
|
||||
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
|
||||
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
|
||||
| `linkStore` ✅ PR 13436 | `src/stores/linkStore.ts` |
|
||||
| `rerouteStore` ✅ PR 13449 | `src/stores/rerouteStore.ts` |
|
||||
|
||||
`linkStore` holds `LinkTopology` records (`src/types/linkTopology.ts`) keyed by
|
||||
target input slot (`` `${targetNodeId}:${targetSlot}` ``) in root-graph-scoped
|
||||
buckets — subgraphs share their root's bucket; floating links and links
|
||||
targeting subgraph outputs live in a per-graph unkeyed side set. `rerouteStore`
|
||||
holds `RerouteChain` records keyed by `RerouteId` in root-graph-scoped buckets;
|
||||
link membership is not stored but derived from the links' `parentId` chains.
|
||||
Design records: [Link Topology Store](link-topology-store.md),
|
||||
[Reroute Chain Store](reroute-chain-store.md).
|
||||
|
||||
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
|
||||
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
|
||||
@@ -260,6 +272,14 @@ link-endpoint records from the relevant stores:
|
||||
Does not perform mutations yet — just queries. Validates that store connectivity
|
||||
data is complete and consistent with the class-based graph.
|
||||
|
||||
> **Status (2026-07-05):** The reroute-membership query shipped as `linkStore` +
|
||||
> `rerouteStore` (PRs 13436, 13449): "what links pass through this reroute" is
|
||||
> derived per root graph by a cached reverse index over the links' `parentId`
|
||||
> chains, and input-side connectivity is one lookup via
|
||||
> `linkStore.isInputSlotConnected()` / `getInputSlotLink()`. Remaining:
|
||||
> slot mirrors (`input.link` / `output.links`), output-side queries, and
|
||||
> execution order.
|
||||
|
||||
**Risk:** Low. Read-only system with equivalence tests.
|
||||
|
||||
---
|
||||
@@ -311,6 +331,12 @@ the system knowing about the callback API.
|
||||
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
|
||||
compatibility API during Phase 4.
|
||||
|
||||
> **Status (2026-07-05):** Link and reroute store registration now funnels
|
||||
> through canonical `LGraph` mutation chokepoints: `_addLink`/`_removeLink` and
|
||||
> `_addReroute`/`_removeReroute` pair every map mutation with store
|
||||
> (un)registration, and `clear()` / subgraph-definition GC unregister whole
|
||||
> graphs. The callback contract above and slot-mirror extraction remain.
|
||||
|
||||
**Risk:** High. Extensions depend on callback ordering and timing. Must be
|
||||
validated against real-world extensions.
|
||||
|
||||
@@ -596,13 +622,15 @@ state between these calls.
|
||||
|
||||
The dedicated stores use per-concern keying strategies:
|
||||
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
| `linkStore` | `` `${targetNodeId}:${targetSlot}` `` (target input slot), root-graph-scoped buckets |
|
||||
| `rerouteStore` | `RerouteId`, root-graph-scoped buckets |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
@@ -629,7 +657,8 @@ Phase 0c (doc fixes) ─────────┤── no dependencies betwe
|
||||
|
||||
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
|
||||
Phase 1b (store state shapes) ─┐── depends on 1a
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 7 others shipped
|
||||
(PR 12617; linkStore PR 13436; rerouteStore PR 13449)
|
||||
|
||||
Phase 2a (Position via layoutStore) ─┐── depends on 1c
|
||||
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
|
||||
|
||||
@@ -18,7 +18,12 @@ Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
LayoutStore["layoutStore (Y.js CRDT)
|
||||
nodeId / linkId / rerouteId → layout"]
|
||||
nodeId / linkId / rerouteId → geometry"]
|
||||
LinkStore["linkStore
|
||||
rootGraphId → targetNodeId:targetSlot
|
||||
→ LinkTopology"]
|
||||
RerouteStore["rerouteStore
|
||||
rootGraphId → RerouteId → RerouteChain"]
|
||||
NodeOutputStore["nodeOutputStore
|
||||
Map<nodeLocatorId, outputs>"]
|
||||
SubgraphNavStore["subgraphNavigationStore
|
||||
@@ -39,7 +44,8 @@ preview exposure state"]
|
||||
|
||||
RS -->|reads| Stores
|
||||
SS -->|reads/writes| Stores
|
||||
CS -->|reads/writes| LayoutStore
|
||||
CS -->|reads/writes| LinkStore
|
||||
CS -->|reads/writes| RerouteStore
|
||||
LS -->|reads/writes| LayoutStore
|
||||
ES -->|reads| NodeOutputStore
|
||||
VS -->|reads/writes| LayoutStore
|
||||
@@ -63,20 +69,31 @@ subgraphId:nodeId"]
|
||||
NID["nodeId (raw)"]
|
||||
LID["linkId (raw)"]
|
||||
RID["rerouteId (raw)"]
|
||||
TIS["targetNodeId:targetSlot
|
||||
(root-graph-scoped bucket)"]
|
||||
end
|
||||
|
||||
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
|
||||
NLID -->|nodeOutputStore| W
|
||||
NID -->|layoutStore| W
|
||||
LID -->|layoutStore| W
|
||||
RID -->|layoutStore| W
|
||||
RID -->|layoutStore, rerouteStore| W
|
||||
TIS -->|linkStore| W
|
||||
```
|
||||
|
||||
`WidgetId = graphId:nodeId:name` is itself a branded string (see
|
||||
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
|
||||
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
|
||||
`rerouteId`. Each store enforces its own key shape; there is no single shared
|
||||
entity-ID type across stores.
|
||||
outputs. `layoutStore` keys geometry records by raw `nodeId` / `linkId` /
|
||||
`rerouteId`. `linkStore` keys `LinkTopology` by **target input slot**
|
||||
(`targetNodeId:targetSlot`) inside root-graph-scoped buckets — the link id is
|
||||
NOT the key; at most one live link can target an input slot, so the target is
|
||||
the natural primary key (see
|
||||
[link-topology-store.md](link-topology-store.md)). Links without a unique
|
||||
target (floating links, `SUBGRAPH_OUTPUT_ID` targets) live in a per-graph
|
||||
unkeyed side set. `rerouteStore` keys `RerouteChain` by raw `rerouteId` in
|
||||
root-graph-scoped buckets (see
|
||||
[reroute-chain-store.md](reroute-chain-store.md)). Each store enforces its own
|
||||
key shape; there is no single shared entity-ID type across stores.
|
||||
|
||||
Note: `graphId` is a scope identifier. It identifies which graph an entity
|
||||
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
|
||||
@@ -177,14 +194,15 @@ target_id, target_slot, type"]
|
||||
B5["resolve()"]
|
||||
end
|
||||
|
||||
subgraph After["linkId-keyed components (layoutStore)"]
|
||||
subgraph After["target-slot-keyed topology (linkStore) + unextracted state"]
|
||||
direction TB
|
||||
A1["LinkEndpoints
|
||||
{ originId, originSlot,
|
||||
targetId, targetSlot, type }"]
|
||||
A2["LinkVisual
|
||||
A1["LinkTopology — SHIPPED
|
||||
{ id, originNodeId, originSlot,
|
||||
targetNodeId, targetSlot, type, parentId? }
|
||||
keyed by targetNodeId:targetSlot"]
|
||||
A2["LinkVisual — not yet extracted
|
||||
{ color, path, centerPos }"]
|
||||
A3["LinkState
|
||||
A3["LinkState — not yet extracted
|
||||
{ dragging, data }"]
|
||||
end
|
||||
|
||||
@@ -198,6 +216,26 @@ targetId, targetSlot, type }"]
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
`LinkTopology` has shipped in `src/stores/linkStore.ts`: `LLink._state` IS the
|
||||
store entry — the class fields are accessors over the store's reactive proxy,
|
||||
so the store and the instance cannot disagree. Registration is first-wins with
|
||||
identity-checked delete/update. See
|
||||
[link-topology-store.md](link-topology-store.md) for the full design record.
|
||||
`LinkVisual` and `LinkState` remain on the `LLink` class.
|
||||
|
||||
### Reroute: RerouteChain (shipped)
|
||||
|
||||
Reroutes follow the same pattern. `RerouteChain { id, parentId?, floating? }`
|
||||
lives in `src/stores/rerouteStore.ts`, keyed by `RerouteId` in
|
||||
root-graph-scoped buckets; `Reroute._chain` is the store entry. Link
|
||||
membership (`Reroute.linkIds` / `floatingLinkIds`) is **not stored** — it is
|
||||
derived per root graph by a cached computed reverse index walking the links'
|
||||
`parentId` chains, replacing ~10 hand-maintained write sites and the
|
||||
`validateLinks` set-repair. See
|
||||
[reroute-chain-store.md](reroute-chain-store.md). Reroute _position_ is not
|
||||
yet migrated: `Reroute.posInternal` remains the source of truth, with the
|
||||
layout store holding a partial `{ id, position }` mirror.
|
||||
|
||||
### Widget: Before vs After
|
||||
|
||||
```mermaid
|
||||
@@ -248,8 +286,8 @@ graph TD
|
||||
direction TB
|
||||
CS["ConnectivitySystem
|
||||
Manages link/slot mutations.
|
||||
Writes: LinkEndpoints, SlotConnection,
|
||||
Connectivity"]
|
||||
Writes: LinkTopology (shipped),
|
||||
SlotConnection (future), Connectivity"]
|
||||
VS["VersionSystem
|
||||
Centralizes change tracking.
|
||||
Replaces 15+ scattered _version++.
|
||||
@@ -312,9 +350,11 @@ graph LR
|
||||
Exe["Execution"]
|
||||
Props["Properties"]
|
||||
WC["WidgetContainer"]
|
||||
LE["LinkEndpoints"]
|
||||
LE["LinkTopology
|
||||
(linkStore — shipped)"]
|
||||
LV["LinkVisual"]
|
||||
SC["SlotConnection"]
|
||||
SC["SlotConnection
|
||||
(output side — future)"]
|
||||
SV["SlotVisual"]
|
||||
WVal["WidgetValue"]
|
||||
WL["WidgetLayout"]
|
||||
@@ -333,7 +373,7 @@ graph LR
|
||||
LS -.->|read| WC
|
||||
|
||||
CS -->|write| LE
|
||||
CS -->|write| SC
|
||||
CS -.->|future write| SC
|
||||
CS -->|write| Con
|
||||
|
||||
ES -.->|read| Con
|
||||
@@ -349,6 +389,14 @@ graph LR
|
||||
VS -.->|read| Con
|
||||
```
|
||||
|
||||
ConnectivitySystem's `LinkEndpoints` write target is realized as
|
||||
`LinkTopology` in `linkStore`. The input side of `SlotConnection`
|
||||
(`input.link`) is subsumed by the linkStore key itself — "which link targets
|
||||
this input slot" is the store's primary index
|
||||
(`isInputSlotConnected` / `getInputSlotLink`) — though the `input.link` slot
|
||||
mirror still exists on the class. The output side (`output.links[]`) remains
|
||||
future extraction work.
|
||||
|
||||
## 4. Dependency Flow
|
||||
|
||||
### Before: Tangled References
|
||||
|
||||
@@ -66,7 +66,7 @@ graph TD
|
||||
Link -.->|"origin_id, target_id"| Node
|
||||
Link -.->|"parentId"| Reroute
|
||||
Slot -.->|"link / links[]"| Link
|
||||
Reroute -.->|"linkIds"| Link
|
||||
Reroute -.->|"linkIds (derived, rerouteStore)"| Link
|
||||
Reroute -.->|"parentId"| Reroute
|
||||
Group -.->|"_children Set"| Node
|
||||
Group -.->|"_children Set"| Reroute
|
||||
@@ -103,10 +103,14 @@ type: ISlotType"]
|
||||
Link -.->|"parentId"| R1["Reroute A"]
|
||||
R1 -.->|"parentId"| R2["Reroute B"]
|
||||
|
||||
R1 -.-|"linkIds Set"| Link
|
||||
R2 -.-|"linkIds Set"| Link
|
||||
R1 -.-|"linkIds (derived)"| Link
|
||||
R2 -.-|"linkIds (derived)"| Link
|
||||
```
|
||||
|
||||
`Reroute.linkIds` / `floatingLinkIds` are read-only accessors derived from the
|
||||
links' own `parentId` chains by `rerouteStore` — membership is never stored
|
||||
(see [reroute-chain-store.md](reroute-chain-store.md)).
|
||||
|
||||
### Subgraph Boundary Connections
|
||||
|
||||
```mermaid
|
||||
@@ -142,14 +146,20 @@ graph TD
|
||||
```mermaid
|
||||
graph LR
|
||||
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
|
||||
origin_id=-1 or target_id=-1"]
|
||||
origin or target = UNASSIGNED_NODE_ID"]
|
||||
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
|
||||
FL -->|"registered in"| SideSet["linkStore unkeyed side set
|
||||
(no unique target slot)"]
|
||||
FL -.->|"may pass through"| Reroute
|
||||
Reroute -.-|"floatingLinkIds Set"| FL
|
||||
Reroute -.-|"floatingLinkIds (derived)"| FL
|
||||
FL -->|"on drop"| Permanent["Permanent LLink
|
||||
(registered in graph._links)"]
|
||||
(graph._links + linkStore target index)"]
|
||||
```
|
||||
|
||||
A floating link's slot attachment is fully encoded in its own endpoints —
|
||||
slots hold no floating-link sets (`slotFloatingLinks()` in `LLink.ts` derives
|
||||
attachment by scanning `graph.floatingLinks`).
|
||||
|
||||
## 3. Rendering
|
||||
|
||||
How LGraphCanvas draws each entity type.
|
||||
@@ -254,7 +264,7 @@ stateDiagram-v2
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: node.connect() or connectSlots()
|
||||
Created --> Registered: graph._links.set(id, link)
|
||||
Created --> Registered: graph._addLink(link)
|
||||
|
||||
state Registered {
|
||||
[*] --> Active
|
||||
@@ -268,12 +278,18 @@ stateDiagram-v2
|
||||
|
||||
note right of Created
|
||||
new LLink(id, type, origin, slot, target, slot)
|
||||
_addLink sets graph._links entry and
|
||||
registers topology in linkStore
|
||||
(keyed by target input slot).
|
||||
Output slot.links[] updated.
|
||||
Input slot.link set.
|
||||
end note
|
||||
|
||||
note right of Removed
|
||||
Removed from graph._links.
|
||||
Removed from graph._links and
|
||||
unregistered from linkStore
|
||||
(via _removeLink or link.disconnect).
|
||||
Link geometry dropped from layoutStore.
|
||||
Orphaned reroutes cleaned up.
|
||||
graph._version incremented.
|
||||
end note
|
||||
@@ -362,6 +378,10 @@ graph TD
|
||||
WVS["WidgetValueStore
|
||||
(Pinia)"]
|
||||
PES["PreviewExposureStore
|
||||
(Pinia)"]
|
||||
LKS["LinkStore
|
||||
(Pinia)"]
|
||||
RRS["RerouteStore
|
||||
(Pinia)"]
|
||||
LM["LayoutMutations
|
||||
(composable)"]
|
||||
@@ -383,10 +403,20 @@ lastRerouteId, lastGroupId)"]
|
||||
SGNode -->|"host-scoped preview exposures"| PES
|
||||
PES -.->|"keyed by host node locator"| SGNode
|
||||
|
||||
%% LayoutMutations
|
||||
%% LinkStore
|
||||
Graph -->|"_addLink()/_removeLink() register/unregister"| LKS
|
||||
Link <-->|"_state IS the store entry (endpoint accessors)"| LKS
|
||||
LKS -.->|"keyed by rootGraphId + targetNodeId:targetSlot"| Link
|
||||
|
||||
%% RerouteStore
|
||||
Graph -->|"_addReroute()/_removeReroute()"| RRS
|
||||
Reroute <-->|"_chain (parentId, floating)"| RRS
|
||||
RRS -.->|"derived linkIds membership"| Reroute
|
||||
|
||||
%% LayoutMutations (geometry only)
|
||||
Node -->|"pos/size setter"| LM
|
||||
Reroute -->|"move()"| LM
|
||||
Link -->|"connectSlots()/disconnect()"| LM
|
||||
Link -->|"disconnect(): drop link geometry"| LM
|
||||
Graph -->|"add()/remove()"| LM
|
||||
|
||||
%% Graph state
|
||||
|
||||
@@ -168,12 +168,24 @@ No central mechanism exists. It's easy to forget an increment (stale render) or
|
||||
|
||||
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
|
||||
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `Reroute.ts` — same pattern at module scope
|
||||
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `LLink.ts:7` — imports the `layoutStore` singleton at module scope; `useLinkStore()` is called inside methods and helpers (needs an active Pinia, but not eagerly at import time)
|
||||
- `Reroute.ts` — `useRerouteStore()` called inside methods for derived membership
|
||||
- `BaseWidget.ts` — imports `useWidgetValueStore`
|
||||
|
||||
These make the domain objects untestable without a Vue app context.
|
||||
|
||||
### Reroute Membership Dual-Writes (solved)
|
||||
|
||||
`Reroute.linkIds` / `floatingLinkIds` were hand-maintained `Set`s written from
|
||||
~10 scattered call sites (connect, disconnect, paste, configure, subgraph
|
||||
pack/unpack, ...), with `Reroute.validateLinks()` repairing the inevitable
|
||||
drift on load. Membership is now derived from the links' own `parentId`
|
||||
chains via `rerouteStore` — the accessors are read-only, the write sites and
|
||||
`validateLinks` are deleted, and orphaned reroutes are pruned by the derived
|
||||
`totalLinks === 0` instead of set-repair. See
|
||||
[reroute-chain-store.md](reroute-chain-store.md) (Decision 1).
|
||||
|
||||
### Change Notification Sprawl
|
||||
|
||||
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from
|
||||
|
||||
119
docs/architecture/link-topology-store.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Link Topology Store
|
||||
|
||||
Date: 2026-07-05 (retroactive design record; implemented in PR #13436)
|
||||
Status: Accepted
|
||||
|
||||
Design record for extracting link topology into a dedicated store per
|
||||
[ADR 0008](../adr/0008-entity-component-system.md). Amends the
|
||||
`LinkEndpoints` component described there. The
|
||||
[Reroute Chain Store](reroute-chain-store.md) builds directly on this
|
||||
store; shared vocabulary lives in the
|
||||
[Domain Glossary](domain-glossary.md).
|
||||
|
||||
## Decision 1: One state object, class reads through it
|
||||
|
||||
`LLink` no longer owns copies of its topology fields. A single plain
|
||||
object,
|
||||
|
||||
```
|
||||
LinkTopology { id, originNodeId, originSlot, targetNodeId, targetSlot,
|
||||
type, parentId? }
|
||||
```
|
||||
|
||||
backs the link: `LLink._state` holds it, and `id`, `type`, `origin_id`,
|
||||
`origin_slot`, `target_id`, `target_slot`, and `parentId` are accessors
|
||||
over it. Registration inserts that same object into the store by
|
||||
reference and re-assigns `_state` to the reactive proxy read back from
|
||||
the bucket, so subsequent class writes are Vue-tracked (the `BaseWidget`
|
||||
pattern — see Decision 4 of the reroute chain store record). There is no
|
||||
store-side copy to drift from the class: the store entry _is_ the
|
||||
class's state.
|
||||
|
||||
The store is runtime state only; `LLink.asSerialisable` reads the same
|
||||
fields it always did, and serialization goldens (key order plus
|
||||
byte-identical round-trips) pin the wire format.
|
||||
|
||||
## Decision 2: Keyed by target input slot, not link id
|
||||
|
||||
The primary index is keyed by `` `${targetNodeId}:${targetSlot}` ``.
|
||||
Two facts make this the right key:
|
||||
|
||||
- **The domain invariant**: at most one live link targets a given input
|
||||
slot. The key is unique by construction for live links.
|
||||
- **The dominant query**: consumers ask "is this input slot connected,
|
||||
and by what?" (`isInputSlotConnected`, `getInputSlotLink`). The key
|
||||
answers it in one lookup with no scan.
|
||||
|
||||
Link _ids_ are only unique per owning graph, not per root graph, so an
|
||||
id-keyed root bucket needed a load-time link-id dedup pass and a
|
||||
first-wins registration protocol to survive collisions across sibling
|
||||
subgraph definitions. Re-keying by target slot deleted both: colliding
|
||||
link ids never touch the index, so workflows load without link-id
|
||||
rewrites.
|
||||
|
||||
Rejected: keeping the id key plus dedup/first-wins. That machinery
|
||||
existed only to compensate for a key the queries never used.
|
||||
|
||||
## Decision 3: Root-graph-scoped buckets, unkeyed side set
|
||||
|
||||
Buckets are scoped by `rootGraph.id` — subgraphs share their root's
|
||||
bucket — matching `widgetValueStore` and the later `rerouteStore`.
|
||||
Re-keying entries to their owning graph was evaluated and rejected: it
|
||||
reintroduces per-graph lifecycle bookkeeping the root scope avoids, and
|
||||
no query wants owning-graph granularity that `graphTopologies` filtering
|
||||
doesn't already provide.
|
||||
|
||||
Links without a unique live target go in a per-graph side `Set` instead
|
||||
of the primary index:
|
||||
|
||||
- **Floating links** — exactly one assigned endpoint; the other is
|
||||
`UNASSIGNED_NODE_ID`. A floating link attached to an input slot does
|
||||
not answer `isInputSlotConnected`, preserving `input.link` semantics.
|
||||
- **Links targeting `SUBGRAPH_OUTPUT_ID`** — the id is a shared
|
||||
constant, so `targetNodeId:targetSlot` is not unique across the
|
||||
subgraphs sharing a root bucket.
|
||||
|
||||
## Decision 4: Registration protocol
|
||||
|
||||
- `registerLink` is **first-wins**: if a different topology already
|
||||
holds the target key, the call returns `undefined` and the loser
|
||||
stays detached. `link._graphId` records a won registration; it is the
|
||||
ownership marker that lets `unregisterLink` and re-registration no-op
|
||||
safely for losers.
|
||||
- `deleteLink` is **identity-checked** (`toRaw` comparison): only the
|
||||
registered topology can vacate its slot.
|
||||
- `updateEndpoint` re-keys atomically — identity-checked displace,
|
||||
patch fields through a reactive wrapper, re-place under the new key —
|
||||
and the move is **authoritative**: an incumbent under the new key is
|
||||
evicted. First-wins only guards _registration_; a re-key is a move of
|
||||
a link that already proved ownership by vacating its old key, so the
|
||||
write wins. This is what keeps slot permutations safe when callers
|
||||
re-key links one write at a time (input reorder, splice shifts): a
|
||||
transiently evicted link re-places itself when its own endpoint write
|
||||
arrives, and every lawful final state is collision-free. The
|
||||
`reactive()` wrap stays even though registered links already hold the
|
||||
proxy: the store is public API and may be handed a raw topology
|
||||
object.
|
||||
|
||||
## Decision 5: Mutation chokepoints
|
||||
|
||||
All `graph._links` map mutation funnels through `LGraph._addLink` /
|
||||
`_removeLink`, which pair the map write with store
|
||||
registration/unregistration (and link-layout cleanup on removal).
|
||||
`addFloatingLink` / `removeFloatingLink` do the same for the floating
|
||||
map. `LLink.disconnect` performs the equivalent effects inline because
|
||||
it only holds a `LinkNetwork`, and unregisters before reroute pruning so
|
||||
derived reroute counts exclude the dying link. `clear()` and
|
||||
subgraph-definition GC unregister whole graphs
|
||||
(`unregisterAllLinkTopologies` / `clearGraph`).
|
||||
|
||||
## Scope
|
||||
|
||||
This design covers link topology (endpoints, type, chain terminus).
|
||||
Link visual state (`color`, path caches) and the layout store's link
|
||||
_geometry_ records are out of scope. The `output.links` and `input.link`
|
||||
slot mirrors have since been deleted — the store is the single source
|
||||
for slot connectivity in both directions (see
|
||||
[output slot connectivity](output-slot-connectivity.md) Decision 6);
|
||||
the remaining fields are deprecated warning getters kept as extension
|
||||
migration telemetry.
|
||||
188
docs/architecture/node-badge-store.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Node Badge Store
|
||||
|
||||
Date: 2026-07-05 (updated 2026-07-06)
|
||||
Status: Implemented — slice A (store + system) and slice B (consumer
|
||||
cutover, resolved decisions below) shipped as
|
||||
`src/stores/nodeBadgeStore.ts`, `src/systems/badgeSystem.ts`,
|
||||
`src/lib/litegraph/src/nodeBadgeDraw.ts`. Follow-up to the
|
||||
[link topology store](link-topology-store.md),
|
||||
[reroute chain store](reroute-chain-store.md), and the
|
||||
[node data store draft](node-data-store.md)
|
||||
|
||||
Design record for extracting node badges off `LGraphNode` instances into
|
||||
a dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
|
||||
going straight to plain-data components — no interim closure storage.
|
||||
Vocabulary: [domain glossary § Badges](domain-glossary.md#badges).
|
||||
|
||||
## Current state (what this replaces)
|
||||
|
||||
`LGraphNode.badges: (LGraphBadge | (() => LGraphBadge))[]` mixes three
|
||||
things: a **core badge closure** (id / lifecycle / source, re-derived
|
||||
independently by the Vue renderer, which skips it positionally via
|
||||
`slice(1)`), **credits badge closures** (`creditsBadgeGetter`,
|
||||
`buildWrapperAwarePriceBadge` — hand-rolled reactive computeds the
|
||||
legacy canvas polls per frame), and a public push surface for
|
||||
extensions. Badge changes are announced by a single manual
|
||||
`node:property:changed` trigger in `usePriceBadge`; plain pushes are
|
||||
invisible. Credits badges are classified by icon identity
|
||||
(`icon.image === componentIconSvg`). Badges never serialize (verified:
|
||||
no `badges` key in `serialize()`/`configure`).
|
||||
|
||||
Every closure is a reactive computed feeding an unreactive array, and
|
||||
the Vue renderer has already re-implemented all of their reactivity as
|
||||
store-tracked dependencies. The design deletes the closures and makes
|
||||
the store rows the single truth both renderers read.
|
||||
|
||||
## Decision 1: Plain `BadgeData` rows, no interim shape
|
||||
|
||||
The store holds only plain data (ADR 0008 component rule):
|
||||
|
||||
```
|
||||
BadgeData {
|
||||
kind: 'core' | 'credits' | 'extension'
|
||||
text: string
|
||||
fgColor?: string
|
||||
bgColor?: string
|
||||
iconKey?: string // resolved via a small icon registry ('credits' → SVG)
|
||||
}
|
||||
```
|
||||
|
||||
No `onClick` (no producer exists; `LGraphButton`/`title_buttons` are a
|
||||
separate surface and out of scope). No `Image` objects — icons are
|
||||
referenced by key. A `commandId` field can be added if a clickable badge
|
||||
requirement ever materialises.
|
||||
|
||||
Rejected: an interim `BadgeEntry { kind, source: LGraphBadge | thunk }`
|
||||
phase. The public element type of `node.badges` breaks either way; one
|
||||
break that lands on the end-state shape beats two.
|
||||
|
||||
## Decision 2: Store shape and keying
|
||||
|
||||
`nodeBadgeStore`: root-graph-scoped buckets (`rootGraph.id`) keyed by
|
||||
`NodeId`, each holding an ordered reactive `BadgeData[]`. Register /
|
||||
unregister / unregister-all trio at the `LGraph.add` / `LGraph.remove` /
|
||||
`clear()` chokepoints, identity-checked deletes — the shipped store
|
||||
conventions.
|
||||
|
||||
Rows are partitioned by `kind` at read time; the positional `slice(1)`
|
||||
convention and icon-identity credits detection are deleted. Display
|
||||
order is kind order (core, credits, extension), not insertion order.
|
||||
|
||||
## Decision 3: A reactive BadgeSystem writes the rows
|
||||
|
||||
One system module owns the recomputation: per registered node, an
|
||||
`effectScope` runs a pure `computeBadges(sources) → BadgeData[]`
|
||||
function inside a thin watch shell and writes the node's rows. Sources
|
||||
are the existing stores: `nodeDefStore`, `settingStore`,
|
||||
`colorPaletteStore`, `useNodePricing` revision refs, `widgetValueStore`,
|
||||
`linkStore` input connectivity. The pure function is the future
|
||||
command-pipeline phase body (ADR 0003 systems ADR); only the scheduler
|
||||
shell changes when that lands.
|
||||
|
||||
`useNodeBadge` / `usePriceBadge` stop pushing closures; their derivation
|
||||
logic moves into `computeBadges`. The manual `'badges'`
|
||||
`node:property:changed` trigger, the `badges` case in
|
||||
`useGraphNodeManager`, and `VueNodeData.badges` are deleted.
|
||||
`usePartitionedBadges` collapses to a store query partitioned by kind;
|
||||
its manual dependency-touching (`trackNodePrice`,
|
||||
`trackSubgraphInnerNodePrices`) moves inside the system.
|
||||
|
||||
## Decision 4: Core badges are system-written rows too
|
||||
|
||||
Core (#id / lifecycle / source) badges are materialized by the system
|
||||
like every other kind, so both renderers consume one uniform row set.
|
||||
This kills the current dual derivation (legacy closure + Vue-side
|
||||
re-derivation). Materialized-by-system is the ECS write path, not a
|
||||
mirror: no other component stores this projection.
|
||||
|
||||
## Decision 5: Legacy canvas consumes rows via a draw cache
|
||||
|
||||
`drawBadges` renders from `BadgeData`, constructing and caching
|
||||
`LGraphBadge` draw objects keyed by row content (the `Reroute` id-badge
|
||||
pattern, memoized). `_boundingRect` hit-test state stays renderer-side.
|
||||
Frame-budget parity per ADR 0008's render mitigations applies.
|
||||
|
||||
## Implementation notes (slice A)
|
||||
|
||||
- Registration is bucket-key presence: the `LGraph.add`/`remove`/`clear`
|
||||
chokepoints call the import-light store trio
|
||||
(`registerNode`/`unregisterNode`/`clearGraph`), and the system watches
|
||||
`registeredNodeIds` to attach/detach per-node effect scopes. Litegraph
|
||||
never imports the system, so the pricing/nodeDef dependency graph
|
||||
(which runtime-imports the litegraph barrel) stays acyclic. The system
|
||||
takes `resolveGraphId`/`resolveNode` seams and is bootstrapped once at
|
||||
the app layer by the `Comfy.NodeBadge` extension (`useNodeBadge`).
|
||||
The graph id is resolved live on every recompute because
|
||||
`LGraph.clear()` and `configure()` reassign the root graph's id; a
|
||||
captured id strands the system on a dead bucket. Row writes are
|
||||
refused for unregistered nodes so a late effect flush cannot
|
||||
resurrect a bucket key the chokepoints deleted. `LGraph.add`
|
||||
registers the node only after `onNodeAdded`: the store write wakes
|
||||
the system's watcher and queues Vue's flush microtask, which must not
|
||||
overtake the paste-scan microtask `useErrorClearingHooks` enqueues
|
||||
from `onNodeAdded`.
|
||||
- The shell still reads `node.constructor.nodeData` and `node.inputs`
|
||||
(untracked instance state) to map pricing input names to slot
|
||||
indices — parity with the legacy closures. Those reads become store
|
||||
lookups when slot data is store-backed (node data store draft).
|
||||
- Core rows are fine-grained — one row per part, tagged
|
||||
`part: 'lifecycle' | 'id' | 'source'` and carrying the raw projected
|
||||
text — with one visibility rule (`badgeTextVisible`, the legacy
|
||||
semantics: `HideBuiltIn` respected for every part). Each renderer owns
|
||||
presentation: the legacy draw cache joins parts in id, lifecycle,
|
||||
source order and truncates; the Vue partition trims lifecycle
|
||||
brackets, and replaces a built-in node's source row with its
|
||||
Comfy-logo chip. Known unification effect: the Vue renderer gains
|
||||
`HideBuiltIn` handling for id and lifecycle badges on built-in nodes.
|
||||
- A credits row is emitted only when the display price label is
|
||||
non-empty; async pricing fills it via the per-node revision ref.
|
||||
|
||||
## Implementation notes (slice B)
|
||||
|
||||
- Wrapper `SubgraphNode` credits rows are aggregated by the system over
|
||||
the inner (recursively collected, non-wrapper) api nodes — the same
|
||||
count basis as the old unconditional per-api-node badges: several
|
||||
collapse to `Partner Nodes x N` (localized), exactly one shows its
|
||||
price with the wrapper's promoted widget values overriding the inner
|
||||
node's own. The aggregation tracks the inner nodes' pricing
|
||||
revisions, the registered-node set, and a structure revision.
|
||||
`useNodePricing` caches labels per signature so a leaf's own read and
|
||||
its wrapper's override read do not evict each other and re-schedule
|
||||
forever.
|
||||
- Legacy `drawBadges` renders the store rows through a per-node
|
||||
memoized `LGraphBadge` cache (decision 5); `iconKey` resolves through
|
||||
a generic litegraph `badgeIconRegistry`, with the `credits` icon
|
||||
registered by the system module.
|
||||
|
||||
## Resolved decisions (2026-07-06 interview)
|
||||
|
||||
1. **Legacy `node.badges` surface — kept as a raw extension-only push
|
||||
surface.** Directive: delete unless actively used. A GitHub-wide scan
|
||||
found three custom-node packs actively writing it
|
||||
(ComfyUI-Wildcard-Pipeline pushes and splices `LGraphBadge`
|
||||
instances; ComfyUI-Enhancement-Utils and ComfyUI-XENodes push
|
||||
per-frame getter thunks for ticking execution-time badges), so the
|
||||
array stays, is no longer written by core/credits code, and both
|
||||
renderers append it after the store rows — thunks still evaluate per
|
||||
frame in the legacy canvas. The earlier "zero third-party writers"
|
||||
scan was wrong.
|
||||
2. **`node.badgePosition` — deleted**, along with the `BadgePosition`
|
||||
enum. Zero uses outside this repo; the legacy canvas now always
|
||||
renders badges top-right (the only surviving behaviour).
|
||||
3. **Subgraph credits aggregation triggers — event-bumped revision.**
|
||||
`litegraph:set-graph`, `subgraph-converted`, and
|
||||
`afterConfigureGraph` bump `bumpSubgraphCreditsRevision()`; swap to
|
||||
reactive `SubgraphStructure` dependencies when that state is
|
||||
store-backed. Aggregation behaviour itself is preserved (explicit
|
||||
user requirement).
|
||||
|
||||
## Scope and sequencing
|
||||
|
||||
Slices: (A) store + `BadgeData` + system for core and credits +
|
||||
chokepoint registration; (B) consumer cutover — Vue partition query,
|
||||
legacy draw cache, trigger/`VueNodeData.badges` deletions, legacy
|
||||
surface shim; extension-facing deprecation notes. Independent of the
|
||||
pending `nodeDataStore` extraction and lands before it, shrinking its
|
||||
Decision 4/6 scope (one less `VueNodeData` field, one less property
|
||||
handler). `PartnerNodesList`'s `find(isCreditsBadge)` migrates to a
|
||||
store query by kind.
|
||||
139
docs/architecture/node-data-store.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Node Data Store
|
||||
|
||||
Date: 2026-07-05
|
||||
Status: Draft (design interview in progress; follow-up to the
|
||||
[link topology store](link-topology-store.md) and
|
||||
[reroute chain store](reroute-chain-store.md))
|
||||
|
||||
Design record for extracting the remaining Node-owned components into a
|
||||
dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
|
||||
eliminating the `VueNodeData` mirror and most of
|
||||
`src/composables/graph/useGraphNodeManager.ts`.
|
||||
|
||||
## Decision 1: One store, one plain state object per node
|
||||
|
||||
`nodeDataStore` holds a single plain `NodeState` object per node,
|
||||
registered by reference with proxy-returning registration (the
|
||||
`BaseWidget` pattern, [reroute store Decision 4](reroute-chain-store.md)).
|
||||
ADR 0008's Node component rows (`NodeVisual`, `Execution`, ...) become
|
||||
field groupings inside `NodeState`, not separate records or stores.
|
||||
|
||||
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `NodeId`.
|
||||
Node-id uniqueness across sibling subgraph definitions is already
|
||||
guaranteed by the load-time dedup pass
|
||||
(`src/lib/litegraph/src/subgraph/subgraphDeduplication.ts`).
|
||||
|
||||
## Decision 2: Field set — what is NodeState, what is elsewhere
|
||||
|
||||
```
|
||||
NodeState {
|
||||
id: NodeId
|
||||
graphId: UUID // owning (sub)graph — partitioning + locator ids
|
||||
type: string // identity, with apiNode?: boolean
|
||||
title: string
|
||||
titleMode?: TitleMode
|
||||
mode: number
|
||||
flags: { collapsed?, pinned?, ghost? }
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
shape?: number
|
||||
resizable?: boolean
|
||||
showAdvanced?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Excluded — owned or derived elsewhere; referencing them here would be a
|
||||
mirror (the hard constraint of this phase):
|
||||
|
||||
| Field | Owner |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `selected` | `canvasStore.selectedNodeIds` (already what `LGraphNode.vue` reads) |
|
||||
| `executing` | `executionStore` via `useNodeExecutionState` (already what Vue reads) |
|
||||
| `hasErrors` | derived from `executionErrorStore` / missing-model/media stores; `node.has_errors` stays a legacy-canvas class field written by `useNodeErrorFlagSync` |
|
||||
| position / size / z | `layoutStore` |
|
||||
| widget values / order | `widgetValueStore` |
|
||||
| input link connectivity | `linkStore` (`getInputSlotLink` / `isInputSlotConnected`) |
|
||||
| `badges` | `nodeBadgeStore` — plain `BadgeData` rows written by a badge system; see [Node Badge Store](node-badge-store.md) |
|
||||
| `inputs` / `outputs` | deferred — see Decision 3 |
|
||||
|
||||
`VueNodeData.selected` and `.executing` are dead fields today (no
|
||||
production consumer reads them); they are deleted, not migrated.
|
||||
|
||||
## Decision 3: Slot arrays deferred; `inputs[].link` readers migrate now
|
||||
|
||||
`NodeInputSlot` / `NodeOutputSlot` are class instances with methods —
|
||||
Slot entity extraction (ADR 0008 `SlotIdentity` etc.) is its own future
|
||||
phase. The slot arrays stay class-side, keeping the `shallowReactive`
|
||||
graft for renderer reactivity.
|
||||
|
||||
What this phase does remove is the last `inputs[].link` dependency: the
|
||||
three remaining readers (`nodeDataUtils.linkedWidgetedInputs` used by
|
||||
`NodeSlots`, and `usePartitionedBadges`' badge computed plus its
|
||||
exported `trackNodePrice`) move to `linkStore.isInputSlotConnected`
|
||||
queries — presence is all any of them needed — which deletes the
|
||||
`node:slot-links:changed` → `refreshNodeInputs` reprojection in
|
||||
`useGraphNodeManager`, the dead `node:slot-errors:changed` handler
|
||||
(zero emitters repo-wide), and the node-removal refresh-all loop.
|
||||
Shipped ahead of the store itself (2026-07-05).
|
||||
|
||||
## Decision 4: Renderer consumes the NodeState proxy, `VueNodeData` dies
|
||||
|
||||
`GraphCanvas` iterates the store's bucket for the active graph (filtered
|
||||
by `NodeState.graphId`) and passes the reactive `NodeState` proxy down
|
||||
the existing prop-drilling path (`LGraphNode` → `NodeHeader` /
|
||||
`NodeSlots` / `NodeContent` / `NodeWidgets`). Children read proxy fields
|
||||
directly; Vue tracks the store state, so the per-property
|
||||
`node:property:changed` → snapshot-rewrite handlers in
|
||||
`useGraphNodeManager` are deleted wholesale.
|
||||
|
||||
Slot arrays reach `NodeSlots` via the existing live-node access
|
||||
(`getNodeByLocatorId`), not through `NodeState`.
|
||||
|
||||
`LGraphNodePreview` constructs a synthetic `NodeState` (as it does a
|
||||
synthetic `VueNodeData` today). `AppModeWidgetList` stops calling
|
||||
`extractVueNodeData` and reads the registered `NodeState` + live node.
|
||||
|
||||
## Decision 5: Registration lifecycle and class adoption
|
||||
|
||||
Follows the shipped trio convention (`LLink` / `Reroute`):
|
||||
|
||||
- `LGraphNode` constructs its `_state: NodeState` at instantiation;
|
||||
`registerNodeState(graph, node)` inserts it by reference and the class
|
||||
adopts the returned reactive proxy; `node._graphId` (root id) is the
|
||||
registration-ownership marker.
|
||||
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
|
||||
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
|
||||
delete (`toRaw` compare) so only the registered state vacates its key.
|
||||
- Class fields become accessors reading through `_state`.
|
||||
`LGraphNodeProperties`' instrumented descriptors keep their
|
||||
get/set + `node:property:changed` emission but store the value in
|
||||
`_state` instead of a closure — trigger consumers (minimap,
|
||||
`useErrorClearingHooks`) keep working unchanged.
|
||||
- Serialization is unaffected: `serialize()` reads the same properties
|
||||
through the accessors.
|
||||
|
||||
## Decision 6: What remains of useGraphNodeManager
|
||||
|
||||
Deleted: `extractVueNodeData`, the `vueNodeData` map, all
|
||||
`node:property:changed` handlers, `syncWithGraph`, `getNode()`
|
||||
(consumers use `graph.getNodeById`), the `node:slot-links:changed`
|
||||
handler (Decision 3).
|
||||
|
||||
Remaining renderer-side lifecycle, slimmed into `useVueNodeLifecycle`
|
||||
(or a small successor):
|
||||
|
||||
- layoutStore seeding on node add/remove (`createNode`/`deleteNode`
|
||||
layout mutations, including the `onAfterGraphConfigured` deferral) —
|
||||
layout is renderer policy, not entity data.
|
||||
- `node:slot-label:changed` slot-array reprojection — dies with the
|
||||
Slot extraction phase.
|
||||
|
||||
## Scope
|
||||
|
||||
Covers node shell state, the `VueNodeData` deletion, and the
|
||||
`inputs[].link` reader migration. Out of scope: Slot entity extraction,
|
||||
`Properties` (`properties` / `properties_info`) and `NodeType` metadata
|
||||
beyond `type`/`apiNode` (`category`, `nodeData`, `description` remain on
|
||||
the class/constructor), badges, `WidgetContainer` (already owned by
|
||||
`widgetValueStore`), and command-pattern mutators (future work per
|
||||
ADR 0003/0008).
|
||||
250
docs/architecture/output-slot-connectivity.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Output Slot Connectivity
|
||||
|
||||
Date: 2026-07-06
|
||||
Status: Accepted (follow-up to the
|
||||
[link topology store](link-topology-store.md); the minimal, non-breaking
|
||||
slice of the deferred `SlotConnection` component work in the
|
||||
[ECS migration plan](ecs-migration-plan.md))
|
||||
|
||||
Design record for answering "is this output slot connected, and by what
|
||||
links?" from `linkStore` instead of the `output.links[]` mirror. It is
|
||||
the output-side counterpart to the input-side migration shipped in
|
||||
[node-data-store Decision 3](node-data-store.md) (#13455). Wiring it lets
|
||||
`SlotConnectionDot` show connected state and removes the last renderer
|
||||
dependency on `output.links[]`.
|
||||
|
||||
This phase does not extract a Slot entity, add a store, or delete any
|
||||
mirror field. Those stay in the deferred `SlotConnection` phase. What it
|
||||
does is the smallest change that clears the reader debt and lets the
|
||||
output dot show connection state.
|
||||
|
||||
## Motivation
|
||||
|
||||
`SlotConnectionDot.vue` colors slots by type only. It carries a
|
||||
`//TODO Support connected/disconnected colors?`. `InputSlot` and
|
||||
`OutputSlot` already declare `connected` / `compatible` props and apply
|
||||
the `lg-slot--connected` / `lg-slot--compatible` classes, but
|
||||
`NodeSlots.vue` passes neither, so the styling path is built but unwired.
|
||||
|
||||
Input connectivity is already answerable through
|
||||
`linkStore.isInputSlotConnected` (shipped in #13455). The matching output
|
||||
query does not exist yet, so the output dot cannot be wired and
|
||||
`output.links[]` stays the only source. This phase adds that query and
|
||||
consumes it.
|
||||
|
||||
## Decision 1: Extend `linkStore`, do not add a store or a Slot entity
|
||||
|
||||
Output connectivity is link topology: the mirror image of the input side
|
||||
the store already owns. It belongs in `linkStore`, not a new `slotStore`.
|
||||
A dedicated Slot store (plain `SlotIdentity` / `SlotVisual` rows, retiring
|
||||
the slot class instances) is the full `SlotConnection` phase and is out
|
||||
of scope here. Introducing it now would be premature per the
|
||||
node-data-store record, which keeps the slot arrays class-side.
|
||||
|
||||
This phase adds read-only accessors over state the store already holds.
|
||||
It needs no new plain-data component, registration trio, chokepoint, or
|
||||
class adoption, though each sibling store required all four.
|
||||
|
||||
## Decision 2: Output connectivity is a derived reverse index
|
||||
|
||||
The store already exposes `graphTopologies(graphId)` over every
|
||||
registered `LinkTopology`. "Which links leave output slot _(node, slot)_"
|
||||
is a reverse index over origin endpoints. This is the same pattern
|
||||
`rerouteStore` uses for link membership
|
||||
([reroute store](reroute-chain-store.md)): a per-graph cached `computed`
|
||||
that Vue invalidates when link state changes. Nothing is stored, so the
|
||||
membership is always derived and cannot drift from the topology.
|
||||
|
||||
```ts
|
||||
type OriginIndex = Map<
|
||||
OriginSlotKey /* `${originNodeId}:${originSlot}` */,
|
||||
Set<LinkTopology>
|
||||
>
|
||||
|
||||
const outputIndexes = new Map<UUID, ComputedRef<OriginIndex>>()
|
||||
|
||||
function outputIndex(graphId: UUID): ComputedRef<OriginIndex> {
|
||||
const existing = outputIndexes.get(graphId)
|
||||
if (existing) return existing
|
||||
const next = computed(() => {
|
||||
const index: OriginIndex = new Map()
|
||||
for (const t of graphTopologies(graphId)) {
|
||||
if (t.originNodeId === UNASSIGNED_NODE_ID) continue
|
||||
const key = originKey(t.originNodeId, t.originSlot)
|
||||
const links = index.get(key) ?? new Set<LinkTopology>()
|
||||
links.add(t)
|
||||
index.set(key, links)
|
||||
}
|
||||
return index
|
||||
})
|
||||
outputIndexes.set(graphId, next)
|
||||
return next
|
||||
}
|
||||
```
|
||||
|
||||
The index spans both collections that `graphTopologies` yields, the keyed
|
||||
targets and the unkeyed side set, so a floating link with an assigned
|
||||
origin (one endpoint only) still reports its output as connected. A
|
||||
`SUBGRAPH_INPUT_ID` origin indexes like any other id.
|
||||
|
||||
## Decision 3: Two public queries, mirroring the input pair
|
||||
|
||||
```ts
|
||||
isOutputSlotConnected(graphId, nodeId, slot): boolean
|
||||
getOutputSlotLinks(graphId, nodeId, slot): ReadonlySet<LinkTopology>
|
||||
```
|
||||
|
||||
These match `isInputSlotConnected` / `getInputSlotLink` in name and shape.
|
||||
The input side returns a single `LinkTopology`, since at most one link
|
||||
targets an input; the output side returns a set, since an output fans out
|
||||
to many. The set holds topologies rather than bare `LinkId`s: floating
|
||||
links draw their ids from a separate counter (`_lastFloatingLinkId`), so
|
||||
an id alone cannot be resolved safely through `graph.links` — a floating
|
||||
id can collide with an unrelated regular link. Topologies carry the
|
||||
endpoints most readers want and make floating links identifiable
|
||||
(`targetNodeId === UNASSIGNED_NODE_ID`) without resolution.
|
||||
|
||||
## Decision 4: Migrate readers incrementally, keep the mirror written by hand
|
||||
|
||||
This follows the #13455 discipline for `input.link`: move the readers to
|
||||
the store, leave the field in place, and keep writing it at the
|
||||
chokepoints.
|
||||
|
||||
`output.links[]` has roughly 200 read sites but only about 12 write
|
||||
sites, all in `LGraphNode`, `LGraph`, and the subgraph paths. Most of the
|
||||
reads are litegraph-internal graph algorithms such as traversal, dedup,
|
||||
and serialization. Those can keep reading the mirror indefinitely; they
|
||||
are not renderer policy and nothing blocks on them.
|
||||
|
||||
Migrated readers: the output dot's connected state (`NodeSlots`), the
|
||||
minimap link extraction (`AbstractMinimapDataSource`), the drag-start
|
||||
disconnect check (`useSlotLinkInteraction`, where one store query replaces
|
||||
a mirror read plus a `slotFloatingLinks` scan), widget value propagation
|
||||
(`widgetValuePropagation`), and matchType link revalidation
|
||||
(`dynamicWidgets.changeOutputType`).
|
||||
|
||||
## Decision 6: Delete the mirrors (implemented; extended to `input.link`)
|
||||
|
||||
The runtime `output.links[]` field and all nine of its write sites are
|
||||
deleted. The same recipe has since been applied to `input.link`: the
|
||||
field is a deprecated warning getter, litegraph and app code read through
|
||||
the slotLinks input helpers (`inputHasLink`, `inputLinkId`, `inputLink`)
|
||||
or `node.isInputConnected` / `node.getInputLink`, serialization derives
|
||||
`inputs[].link` from the store, and the mirror-carried association
|
||||
shuffles were reworked — `fixLinkInputSlots` consumes the serialized
|
||||
graph data, dynamicWidgets' group rebuilds carry slot→link association
|
||||
in a module-scoped WeakMap refreshed from the store, and link
|
||||
deduplication selects survivors from the store registration (the
|
||||
`repairInputLinks` mirror repair is gone; the derived view cannot be
|
||||
wrong). The store is the single source; litegraph internals read through
|
||||
the pure helpers in `node/slotLinks.ts` (`outputHasLinks`,
|
||||
`outputLinkIds`, `outputLinks`), and `NodeOutputSlot.isConnected`,
|
||||
`serialize`, and `configure` derive from the store. Details:
|
||||
|
||||
- **Wire format unchanged.** `outputAsSerialisable` / `toJSON` emit the
|
||||
serialized `outputs[].links` array from the store, sorted ascending by
|
||||
id (a determinism choice — equal to push order for organically built
|
||||
graphs) and `null` when empty. `configure` fires its output
|
||||
`onConnectionsChange` callbacks from the serialized argument.
|
||||
- **Floating links are never returned by link queries.** They are
|
||||
reroute-chain scaffolding, named by `isFloatingTopology`
|
||||
(`src/types/linkTopology.ts`). `isOutputSlotConnected` /
|
||||
`getOutputSlotLinks` see fully-assigned links only, matching the
|
||||
mirror they replace; the one consumer with legacy floating-aware
|
||||
behavior (the slot-drag disconnect check) keeps its own
|
||||
`slotFloatingLinks` scan.
|
||||
- **Extension compat = deprecation telemetry, not compatibility.** A
|
||||
read-only prototype getter on `NodeOutputSlot` returns a fresh
|
||||
store-derived `LinkId[] | null` and fires `warnDeprecated`. Writes fire
|
||||
their own `warnDeprecated` naming the replacement APIs
|
||||
(`node.connect()` / `node.disconnectOutput()`) and are otherwise
|
||||
ignored — the store stays authoritative and legacy writers degrade
|
||||
gracefully instead of crashing. A bare accessor-only property would
|
||||
instead throw an unactionable `TypeError` in strict mode.
|
||||
`INodeOutputSlot` keeps `links` as `@deprecated readonly` so
|
||||
`'links' in slot` discriminants still compile and hold at runtime via
|
||||
the prototype. The mirror keys are non-enumerable: `{ ...slot }`,
|
||||
`Object.assign({}, slot)`, and `Object.keys(slot)` do not carry
|
||||
`link` / `links` (use the store queries to snapshot connectivity);
|
||||
`JSON.stringify` still emits them via `toJSON`. `addInput` / `addOutput`
|
||||
drop a stale `link` / `links` key from `extra_info` instead of letting
|
||||
`Object.assign` collide with the accessor — the store is not consulted
|
||||
or mutated by such values.
|
||||
- **Duck-typed slots are upgraded in place.** `_setConcreteSlots` writes
|
||||
the concrete `NodeInputSlot` / `NodeOutputSlot` wrappers back into
|
||||
`node.inputs` / `node.outputs`. The concrete classes resolve their slot
|
||||
index by identity (`inputs.indexOf(this)`), so a wrapper that is not
|
||||
the array entry would permanently read as disconnected. Plain-object
|
||||
slots pushed directly into the arrays are therefore upgraded at the
|
||||
next concretisation (`configure`, paste/convert paths, every canvas
|
||||
draw); until then their stale `link` value is dead data. Extensions
|
||||
should use `node.addInput()` / `node.addOutput()` instead of pushing
|
||||
literals.
|
||||
- **Serialized-data operators are untouched.** `linkFixer` (serialized
|
||||
branch), `migrateReroute`, and `unpackSubgraph`'s pre-configure strip
|
||||
operate on the wire format, which still carries `links`.
|
||||
- **Behavior changes, deliberate:** `PrimitiveNode.onLastDisconnect`
|
||||
fires on disconnect-all (the stale mirror previously suppressed it);
|
||||
`disconnectInput` passes `link_info.origin_slot` — not the old
|
||||
mirror-array index — as the OUTPUT slot in `onConnectionsChange`;
|
||||
serialization emits `null` where a lingering `[]` used to persist.
|
||||
|
||||
Extension migration map: presence → `node.isOutputConnected(slot)` /
|
||||
`slot.isConnected`; enumerate targets → `node.getOutputNodes(slot)`;
|
||||
mutate → `node.connect(...)` / `node.disconnectOutput(slot, target?)`.
|
||||
App/Vue code uses `useLinkStore().getOutputSlotLinks(...)` (reactive).
|
||||
|
||||
## Decision 5: Wire connected state into the dots (the payoff)
|
||||
|
||||
`NodeSlots.vue` passes `connected` to each slot:
|
||||
|
||||
- input: `linkStore.isInputSlotConnected(rootGraphId, nodeId, index)`
|
||||
(already available)
|
||||
- output: `linkStore.isOutputSlotConnected(rootGraphId, nodeId, index)`
|
||||
(Decision 3)
|
||||
|
||||
`InputSlot` and `OutputSlot` already forward `connected` to the
|
||||
`lg-slot--connected` class. `SlotConnectionDot` needs no prop of its own:
|
||||
the wrapper class is an ancestor styling hook
|
||||
(`.lg-slot--connected .slot-dot`), so the visual is one CSS rule away once
|
||||
the design-standards check (open question 3) picks it. Threading a
|
||||
`connected` prop into the dot before that would be plumbing with no
|
||||
consumer. `compatible` stays driven by the existing drag state
|
||||
(`useSlotLinkDragUIState`), which this phase leaves alone.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the derived output-side index, the two queries, the output-dot
|
||||
reader migration, and wiring `connected` from `NodeSlots` into
|
||||
`InputSlot` / `OutputSlot` (whose `lg-slot--connected` class reaches the
|
||||
dot via CSS).
|
||||
|
||||
Since implemented by Decision 6: `output.links` reader migration, write
|
||||
sites, and field deletion.
|
||||
|
||||
Out of scope, each a piece of the deferred `SlotConnection` phase:
|
||||
|
||||
- Slot entity extraction: `SlotIdentity`, `SlotVisual`, and retiring the
|
||||
`NodeInputSlot` / `NodeOutputSlot` class instances and their
|
||||
`shallowReactive` graft.
|
||||
- A `compatible`-state source beyond the current drag UI state.
|
||||
- Floating/regular link-id counter unification (mint floating ids from
|
||||
the shared `state.lastLinkId`) — only if the internal wart ever bites;
|
||||
nothing resolves store-returned ids against `graph.links` across the
|
||||
id spaces anymore.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Index granularity.** One `computed` per graph rebuilds the whole
|
||||
origin index on any link change in that graph, the same cost model as
|
||||
`rerouteStore`. If profiling on large graphs shows this is hot, the
|
||||
fallback is an incrementally maintained `Map` updated in `place` and
|
||||
`displace`, which is more code and carries drift risk. Start with the
|
||||
derived version and measure before optimizing.
|
||||
2. **Return type of `getOutputSlotLinks`.** Resolved: a set of
|
||||
`LinkTopology`, per Decision 3. Bare ids looked sufficient until the
|
||||
reader migration surfaced both the endpoint needs (minimap, widget
|
||||
value propagation) and the floating-id collision hazard.
|
||||
3. **Dot visual.** What "connected" looks like on the dot (fill, ring, or
|
||||
opacity) is a design-standards question rather than an architecture
|
||||
one. Check the Comfy Design Standards before implementing Decision 5.
|
||||
@@ -6,20 +6,30 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Six dedicated stores extract entity state out of class instances into focused,
|
||||
Eight dedicated stores extract entity state out of class instances into focused,
|
||||
queryable registries, each owning one concern. Promoted value-widget topology is
|
||||
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
|
||||
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
|
||||
`WidgetId`.
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ---------------------------- | ----------------- | --------------------------------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link geometry, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
|
||||
| LinkStore | `LLink` | Root graph | `` `${targetNodeId}:${targetSlot}` `` (target input slot) | Plain `LinkTopology` object |
|
||||
| RerouteStore | `Reroute` | Root graph | `RerouteId` | Plain `RerouteChain` object |
|
||||
|
||||
**Update (2026-07-05):** `LinkStore` (`src/stores/linkStore.ts`, PR #13436) and
|
||||
`RerouteStore` (`src/stores/rerouteStore.ts`, PR #13449) hold plain-data records
|
||||
in reactive `Map` buckets — not Y.js — scoped by root graph (subgraphs share
|
||||
their root's bucket). Floating links and links targeting subgraph outputs live
|
||||
in a per-graph unkeyed side set. Design records:
|
||||
[Link Topology Store](link-topology-store.md),
|
||||
[Reroute Chain Store](reroute-chain-store.md).
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -149,28 +159,37 @@ The most architecturally advanced extraction — uses Y.js CRDTs for collaborati
|
||||
|
||||
```
|
||||
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
|
||||
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → { id, position }
|
||||
```
|
||||
|
||||
**Update (2026-07-05):** The link-connectivity mirror (`ylinks`, `LinkData`,
|
||||
`createLink`/`removeLink` mutations, `findLinksConnectedToNode`) was deleted
|
||||
when link topology moved to `LinkStore` (PR #13436). LayoutStore now owns only
|
||||
link/segment _geometry_ caches, and `RerouteData` carries `{ id, position }`
|
||||
only — the write-only `parentId`/`linkIds` fields were removed.
|
||||
|
||||
### Write API
|
||||
|
||||
`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API:
|
||||
|
||||
- `moveNode(graphId, nodeId, pos)`
|
||||
- `resizeNode(graphId, nodeId, size)`
|
||||
- `setNodeZIndex(graphId, nodeId, zIndex)`
|
||||
- `createLink(graphId, linkId, ...)`
|
||||
- `removeLink(graphId, linkId)`
|
||||
- `moveReroute(graphId, rerouteId, pos)`
|
||||
- `moveNode(nodeId, pos)` / `batchMoveNodes(...)`
|
||||
- `resizeNode(nodeId, size)`
|
||||
- `setNodeZIndex(nodeId, zIndex)` / `bringNodeToFront(nodeId)`
|
||||
- `createNode(nodeId, layout)` / `deleteNode(nodeId)`
|
||||
- `createReroute(rerouteId, pos)` / `deleteReroute(rerouteId)` /
|
||||
`moveReroute(rerouteId, pos, prevPos)`
|
||||
|
||||
(`createLink`/`removeLink` are gone — link topology is `LinkStore`'s concern.)
|
||||
|
||||
### The Scattered Access Problem
|
||||
|
||||
This composable is called at **module scope** in domain objects:
|
||||
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()`
|
||||
- `Reroute.ts` — same pattern
|
||||
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()`
|
||||
- `LGraphNode.ts` — imported and called in methods
|
||||
- `LLink.ts` no longer uses `useLayoutMutations` (its layout writes went away
|
||||
with the `ylinks` mirror), but it still imports `layoutStore` and
|
||||
`useLinkStore` at module scope
|
||||
|
||||
These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context.
|
||||
|
||||
@@ -234,12 +253,14 @@ graph TD
|
||||
|
||||
Each store owns the identity scheme that fits its concern:
|
||||
|
||||
| Store | Key Format | Key Type | Type-Safe? |
|
||||
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
|
||||
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Key Type | Type-Safe? |
|
||||
| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------- |
|
||||
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| LinkStore | `` `${targetNodeId}:${targetSlot}` `` (root-scoped buckets) | Composite string | No |
|
||||
| RerouteStore | `RerouteId` (root-scoped buckets) | branded number | Yes (`RerouteId`) |
|
||||
|
||||
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
|
||||
which carries its scope and survives renames at the store layer. The remaining
|
||||
@@ -286,22 +307,26 @@ graph TD
|
||||
|
||||
subgraph Link["LLink"]
|
||||
L_ext["Extracted:
|
||||
- layout data → LayoutStore"]
|
||||
- id, endpoints, type, parentId → LinkStore
|
||||
(LLink._state IS the store entry;
|
||||
fields are accessors over it)
|
||||
- segment geometry → LayoutStore"]
|
||||
L_rem["Remains on class:
|
||||
- origin_id, target_id
|
||||
- origin_slot, target_slot
|
||||
- type, color, path
|
||||
- color, path, _pos, _centreAngle
|
||||
- data, _dragging
|
||||
- disconnect(), resolve()"]
|
||||
end
|
||||
|
||||
subgraph Reroute["Reroute"]
|
||||
R_ext["Extracted:
|
||||
- pos → LayoutStore"]
|
||||
- pos → LayoutStore (partial mirror;
|
||||
posInternal still truth)
|
||||
- parentId, floating → RerouteStore
|
||||
- linkIds, floatingLinkIds → derived
|
||||
from links' parentId chains"]
|
||||
R_rem["Remains on class:
|
||||
- parentId, linkIds
|
||||
- floatingLinkIds
|
||||
- color, draw()
|
||||
- posInternal (position truth)
|
||||
- colour, draw()
|
||||
- findSourceOutput()"]
|
||||
end
|
||||
|
||||
@@ -346,15 +371,19 @@ graph TD
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | endpoints, type, parentId (LinkStore, via `_state` proxy); segment geometry (LayoutStore) | visual (color, path), drag state, connectivity methods | LinkEndpoints ✅, LinkVisual, LinkState | Small — topology shipped (PR #13436); visual state and slot mirrors remain |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | parentId, floating (RerouteStore); pos (LayoutStore, partial mirror) | position truth (posInternal), visual, chain traversal | Position, RerouteChain ✅, RerouteVisual | Small — chain shipped (PR #13449); position ownership and visual remain |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
`RerouteChain` supersedes the earlier `RerouteLinks` component (ADR 0008
|
||||
amendment, 2026-07-04): link membership is never stored — it is derived from
|
||||
the links' `parentId` chains over `LinkStore`.
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
@@ -362,8 +391,11 @@ Based on existing progress and problem severity:
|
||||
|
||||
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
|
||||
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
|
||||
3. **Link** — small component set, high coupling pain
|
||||
3. **Link** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
|
||||
(`input.link`/`output.links`) and visual state remain
|
||||
4. **Slot** — no extraction yet, but small and self-contained
|
||||
5. **Reroute** — partially extracted, moderate complexity
|
||||
(`SlotConnection` input side now answerable via LinkStore)
|
||||
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
|
||||
ownership and visual remain
|
||||
6. **Group** — no extraction, but least coupled to other entities
|
||||
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)
|
||||
|
||||
118
docs/architecture/reroute-chain-store.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Reroute Chain Store
|
||||
|
||||
Date: 2026-07-04
|
||||
Status: Accepted (design review; follow-up to the
|
||||
[link topology store](link-topology-store.md), PR #13436)
|
||||
|
||||
Design record for extracting reroute connectivity state into a dedicated
|
||||
store per [ADR 0008](../adr/0008-entity-component-system.md). Amends the
|
||||
`RerouteLinks` component described there.
|
||||
|
||||
## Decision 1: The chain is the single source of truth for membership
|
||||
|
||||
"Which links pass through reroute R" is encoded twice today: each link's
|
||||
`parentId` chain (`link.parentId` names the terminal reroute;
|
||||
`reroute.parentId` walks upstream), and per-reroute `linkIds` /
|
||||
`floatingLinkIds` Sets maintained by hand at roughly ten write sites
|
||||
(`LGraphNode.connect`, `LLink.disconnect`, `LGraph.addFloatingLink` /
|
||||
`removeFloatingLink` / `createReroute`, subgraph unpack,
|
||||
`SubgraphInput/Output.connect`, `LinkConnector`), with
|
||||
`Reroute.validateLinks` repairing drift at configure time.
|
||||
|
||||
The chain is primary. Membership becomes a derived reverse index:
|
||||
|
||||
```
|
||||
linksThrough(R) = { L : R ∈ chain(L.parentId) }
|
||||
```
|
||||
|
||||
computed over the link store's topologies plus the reroute chain states,
|
||||
cached, and invalidated on chain mutation. The `Reroute` class exposes
|
||||
`linkIds` / `floatingLinkIds` as derived accessors. The membership write
|
||||
sites and `validateLinks` are deleted.
|
||||
|
||||
Rejected: storing membership Sets in the store (the `LLink._state`
|
||||
pattern applied to whole membership). It keeps one _stored_ copy but
|
||||
preserves the domain-level redundancy — chain and membership can still
|
||||
disagree, and every dual write site survives.
|
||||
|
||||
## Decision 2: Store shape and keying
|
||||
|
||||
`rerouteStore` holds per-reroute chain state objects, registered by
|
||||
reference (the class reads through them, mirroring `LLink._state`):
|
||||
|
||||
```
|
||||
RerouteChain { id, parentId?, floating? }
|
||||
```
|
||||
|
||||
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `RerouteId`,
|
||||
matching `widgetValueStore` and `linkStore` scoping. Owning-graph buckets
|
||||
were evaluated and rejected for the link store (2026-07-04) and are
|
||||
rejected here for the same reasons.
|
||||
|
||||
`RerouteId` is the domain key because runtime allocation is already
|
||||
per-root-unique: `Subgraph.state` delegates to the root graph's state, so
|
||||
all graphs increment one shared `lastRerouteId` counter.
|
||||
|
||||
## Decision 3: Load-time reroute-id dedup
|
||||
|
||||
Serialized workflows from older frontends or external tools can carry
|
||||
colliding reroute ids across sibling subgraph definitions. Today this is
|
||||
tolerated only because `graph.reroutes` is a per-graph map; a root-scoped
|
||||
bucket would break on it, and the layout store's bare-`rerouteId` keying
|
||||
already collides latently.
|
||||
|
||||
On configure, colliding subgraph reroute ids are rewritten to fresh ids
|
||||
from the shared counter, patching that subgraph's `link.parentId` and
|
||||
`reroute.parentId` references — the same repair the node-id and link-id
|
||||
dedup passes already perform. Rewritten ids serialize back changed.
|
||||
|
||||
Rejected: a first-wins registration protocol (losers detached via an
|
||||
ownership flag). That is the apparatus the target-keyed link store
|
||||
redesign existed to delete.
|
||||
|
||||
## Decision 4: Registration returns the reactive proxy
|
||||
|
||||
The derived membership index requires observable chain mutation.
|
||||
`BaseWidget` already establishes the pattern: the store bucket is a
|
||||
`reactive(Map)`, registration inserts the raw state and returns the
|
||||
value read back from the map — the reactive proxy — and the class holds
|
||||
that proxy as `_state` (`BaseWidget.setNodeId`,
|
||||
`widgetValueStore.registerWidget`). Every subsequent class write goes
|
||||
through the proxy and is tracked.
|
||||
|
||||
`rerouteStore.registerReroute` follows this: it returns the proxy and
|
||||
the `Reroute` class reads and writes chain state through it, so
|
||||
`reroute.parentId` mutations are tracked with no action chokepoint.
|
||||
|
||||
`LLink` previously deviated — `registerLinkTopology` left `link._state`
|
||||
raw, which is why `linkStore.updateEndpoint` must re-wrap with
|
||||
`reactive()` before patching, and why bare `link.parentId` writes were
|
||||
invisible to effects. This migration aligns it with the `BaseWidget`
|
||||
pattern: link registration re-assigns `_state` to the store proxy,
|
||||
making `link.parentId` writes tracked. `updateEndpoint` keeps its
|
||||
`reactive()` wrap as a guard — the store is public API and may be
|
||||
handed a raw topology object.
|
||||
|
||||
## Decision 5: Serialization
|
||||
|
||||
`SerialisableReroute.linkIds` remains in the wire format, emitted from
|
||||
the derived membership in ascending link-id order. All runtime producers
|
||||
append links in ascending id order and the serialization goldens hold
|
||||
ascending arrays, so chain-consistent workflows round-trip byte-identical.
|
||||
|
||||
Workflows whose stored `linkIds` contradict their chains are repaired on
|
||||
the next save (membership re-derived, stale ids dropped, order
|
||||
normalized). A reroute that no chain reaches at all is dropped at load —
|
||||
where `validateLinks` used to preserve it if its stored ids named live
|
||||
links — because the chain is primary. No compatibility shim for such
|
||||
files. `floatingLinkIds` stays unserialized, rebuilt at runtime as
|
||||
before.
|
||||
|
||||
## Scope
|
||||
|
||||
This design covers chain state, derived membership, and the `LLink`
|
||||
proxy retrofit from Decision 4 (a small self-contained change, done
|
||||
first). Reroute visual state (`_colour`, badge) is out of scope. The
|
||||
`Reroute.pos` class field still mirrors the layout store's position;
|
||||
that pre-existing duplication is a separate concern, not addressed
|
||||
here.
|
||||
25
global.d.ts
vendored
@@ -41,6 +41,29 @@ interface GtagFunction {
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
type SyftDataTraits = Record<string, string | number | null | undefined>
|
||||
|
||||
interface SyftDataPendingFetch {
|
||||
args: unknown[]
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
interface SyftDataClient {
|
||||
identify(email: string, traits?: SyftDataTraits): void
|
||||
signup(email: string, traits?: SyftDataTraits): void
|
||||
track(event: string, traits?: SyftDataTraits): void
|
||||
page(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
fi?: SyftDataPendingFetch[]
|
||||
fetchID?: (...args: unknown[]) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
|
||||
interface SyftDisabledClient {
|
||||
enable: () => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
@@ -78,6 +101,8 @@ interface Window {
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
syft?: SyftDataClient | SyftDisabledClient
|
||||
syftc?: { sourceId?: string; enabled?: boolean }
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
cleanupSVG,
|
||||
importDirectorySync,
|
||||
isEmptyColor,
|
||||
parseColors,
|
||||
runSVGO
|
||||
} from '@iconify/tools'
|
||||
import { cleanupSVG, importDirectorySync, runSVGO } from '@iconify/tools'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export const COMFY_ICON_PREFIX = 'comfy'
|
||||
@@ -13,13 +7,6 @@ const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
|
||||
|
||||
let cached
|
||||
|
||||
/**
|
||||
* Load the comfy icon folder as a normalized Iconify icon set.
|
||||
*
|
||||
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
|
||||
* runs internally so monotone hardcoded colors become `currentColor` and
|
||||
* outer-svg attributes like `fill="none"` survive the body extraction.
|
||||
*/
|
||||
export function loadComfyIconSet() {
|
||||
if (cached) return cached
|
||||
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
|
||||
@@ -32,18 +19,6 @@ export function loadComfyIconSet() {
|
||||
}
|
||||
try {
|
||||
cleanupSVG(svg)
|
||||
const palette = parseColors(svg)
|
||||
const colors = palette.colors.filter(
|
||||
(color) => typeof color === 'string' || !isEmptyColor(color)
|
||||
)
|
||||
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
|
||||
if (totalColors < 2) {
|
||||
parseColors(svg, {
|
||||
defaultColor: 'currentColor',
|
||||
callback: (_attr, colorStr, color) =>
|
||||
!color || isEmptyColor(color) ? colorStr : 'currentColor'
|
||||
})
|
||||
}
|
||||
runSVGO(svg)
|
||||
iconSet.fromSVG(name, svg)
|
||||
} catch {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
|
||||
const SCALE = 1.2
|
||||
|
||||
const options = {
|
||||
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
|
||||
scale: SCALE
|
||||
}
|
||||
|
||||
export default plugin(({ matchComponents }) => {
|
||||
matchComponents({
|
||||
icon: (icon) => {
|
||||
try {
|
||||
return getDynamicCSSRules(icon, options)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
49
packages/design-system/src/css/iconifyDynamicPlugin.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
|
||||
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
|
||||
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
|
||||
import { matchIconName } from '@iconify/utils/lib/icon/name'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
|
||||
const SCALE = 1.2
|
||||
|
||||
const options = {
|
||||
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
|
||||
scale: SCALE
|
||||
}
|
||||
|
||||
function getModeCSSRules(icon: string, mode: 'mask' | 'background') {
|
||||
const nameParts = icon.split(/--|:/)
|
||||
if (nameParts.length !== 2) return {}
|
||||
|
||||
const [prefix, name] = nameParts
|
||||
if (!(prefix.match(matchIconName) && name.match(matchIconName))) return {}
|
||||
|
||||
const iconSet =
|
||||
prefix === COMFY_ICON_PREFIX ? loadComfyIconSet() : loadIconSet(prefix)
|
||||
if (!iconSet) return {}
|
||||
|
||||
const generated = getIconsCSSData(iconSet, [name], {
|
||||
iconSelector: '.icon',
|
||||
mode
|
||||
})
|
||||
if (generated.css.length !== 1) return {}
|
||||
|
||||
const size = { width: `${SCALE}em`, height: `${SCALE}em` }
|
||||
return { ...generated.common?.rules, ...size, ...generated.css[0].rules }
|
||||
}
|
||||
|
||||
export default plugin(({ matchComponents }) => {
|
||||
matchComponents({
|
||||
icon: (icon) => {
|
||||
try {
|
||||
return getDynamicCSSRules(icon, options)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
'icon-img': (icon) => getModeCSSRules(icon, 'background'),
|
||||
'icon-mask': (icon) => getModeCSSRules(icon, 'mask')
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@plugin "./iconifyDynamicPlugin.js";
|
||||
@plugin "./iconifyDynamicPlugin.ts";
|
||||
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,claude,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,claude,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-ash-800);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--node-component-header-surface: var(--color-smoke-200);
|
||||
--node-component-outline: var(--color-black);
|
||||
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
|
||||
--node-component-slot-dot-outline-opacity-mult: 1;
|
||||
@@ -343,7 +343,7 @@
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-smoke-800);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-header-surface: var(--color-charcoal-700);
|
||||
--node-component-outline: var(--color-white);
|
||||
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
|
||||
--node-component-slot-dot-outline-opacity: 10%;
|
||||
@@ -727,14 +727,14 @@ body {
|
||||
/* Shared markdown content styling for consistent rendering across components */
|
||||
.comfy-markdown-content {
|
||||
/* Typography */
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: 22px; /* text-[22px] */
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -745,7 +745,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h2 {
|
||||
font-size: 18px; /* text-[18px] */
|
||||
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
@@ -756,7 +756,7 @@ body {
|
||||
}
|
||||
|
||||
.comfy-markdown-content h3 {
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||