Compare commits
6 Commits
drjkl/the-
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9467a1cb2d | ||
|
|
3afe1d5eb5 | ||
|
|
436e7ee73f | ||
|
|
1c56640468 | ||
|
|
c25e88d366 | ||
|
|
cfe4e0f444 |
86
.claude/settings.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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,27 +134,6 @@ 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 \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,7 +95,6 @@ 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,3 +55,6 @@ 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' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,13 +32,12 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(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'))))))
|
||||
(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,7 +21,6 @@ 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') ||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -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 max-width="xl" :label="eyebrow" align="start">
|
||||
<SectionHeader :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,8 +38,7 @@ 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.supportedModels', locale), href: routes.models }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,41 +33,36 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</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>
|
||||
<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>
|
||||
</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/mcp-card.webp',
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
cta: {
|
||||
label: t('cta.getStarted', locale),
|
||||
label: t('cta.tryWorkflow', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
href: routes.mcp
|
||||
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@@ -82,7 +82,6 @@ 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,10 +26,6 @@ const translations = {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
'cta.getStarted': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'cta.watchNow': {
|
||||
en: 'Watch Now',
|
||||
'zh-CN': '立即观看'
|
||||
@@ -2187,7 +2183,6 @@ 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': '联系' },
|
||||
@@ -2201,16 +2196,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: COMFY MCP',
|
||||
'zh-CN': '全新发布:Comfy MCP'
|
||||
en: 'New Release: Seedance 2.0',
|
||||
'zh-CN': '全新发布:Seedance 2.0'
|
||||
},
|
||||
'nav.featuredProductsAlt': {
|
||||
en: 'Comfy MCP feature image',
|
||||
'zh-CN': 'Comfy MCP 精选图片'
|
||||
en: 'Seedance 2.0 release feature image',
|
||||
'zh-CN': 'Seedance 2.0 发布精选图片'
|
||||
},
|
||||
'nav.featuredProductsCtaAria': {
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
en: 'Try the Seedance 2.0 workflow',
|
||||
'zh-CN': '试用 Seedance 2.0 工作流'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [10, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
@@ -38,7 +38,7 @@
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"id": "outer-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
@@ -71,7 +71,7 @@
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
|
||||
"type": "inner-subgraph-with-promoted-missing-model",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
@@ -105,7 +105,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
|
||||
"id": "inner-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
|
||||
@@ -537,6 +537,7 @@ 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,10 +6,6 @@ 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
|
||||
@@ -45,6 +41,10 @@ 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,30 +64,29 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockCustomTemplates()
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockCustomTemplates(): Promise<void> {
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '{}',
|
||||
body: JSON.stringify(customTemplates),
|
||||
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({
|
||||
@@ -99,8 +98,9 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
@@ -114,8 +114,12 @@ export class TemplateHelper {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
@@ -125,6 +129,15 @@ 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,6 +7,10 @@ export const templateApiFixture = base.extend<{
|
||||
templateApi: TemplateHelper
|
||||
}>({
|
||||
templateApi: async ({ page }, use) => {
|
||||
await use(createTemplateHelper(page))
|
||||
const templateApi = createTemplateHelper(page)
|
||||
|
||||
await use(templateApi)
|
||||
|
||||
await templateApi.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -28,12 +28,7 @@ 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'
|
||||
|
||||
// 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
|
||||
const BOOT_FEATURES = { team_workspaces_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: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
@@ -46,7 +46,6 @@ 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: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
@@ -691,8 +691,7 @@ test(
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
const { width, height } = (await stepsSlot.boundingBox())!
|
||||
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
|
||||
await stepsSlot.hover()
|
||||
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 }) => {
|
||||
test.beforeEach(async ({ comfyPage, templateApi }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
@@ -25,6 +25,8 @@ 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 ({
|
||||
@@ -54,7 +56,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -99,7 +101,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -141,7 +143,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -182,7 +184,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
@@ -220,7 +222,7 @@ test.describe(
|
||||
})
|
||||
])
|
||||
)
|
||||
await templateApi.mock()
|
||||
await templateApi.mockIndex()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 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 ? -30 : -80 })
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -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(ksampler.title, { x: 100 })
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
@@ -1270,57 +1270,3 @@ test(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Floating reroutes', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const previewNodePos = { position: { x: 800, y: 200 } }
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image', previewNodePos)
|
||||
const previewNode =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
|
||||
await test.step('Create floating reroute', async () => {
|
||||
const reroutePos = { targetPosition: { x: 700, y: 400 } }
|
||||
await previewNode
|
||||
.getSlot('images')
|
||||
.first()
|
||||
.dragTo(comfyPage.canvas, reroutePos)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Add Reroute')
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
})
|
||||
|
||||
await test.step('Connect node on top of floating link', async () => {
|
||||
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadNode
|
||||
.getSlot('IMAGE')
|
||||
.first()
|
||||
.dragTo(previewNode.getSlot('images').first())
|
||||
})
|
||||
|
||||
await test.step('Create node from floating reroute', async () => {
|
||||
await comfyPage.canvas.dragTo(comfyPage.canvas, {
|
||||
sourcePosition: { x: 680, y: 400 },
|
||||
targetPosition: { x: 500, y: 500 }
|
||||
})
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('LoadImage')
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
if (!graph || graph.links.size !== 1) return 'invalid link count'
|
||||
if (graph.reroutes.size !== 1) return 'invalid reroutes count'
|
||||
|
||||
const linkId = graph.nodes.find((n) => n.title === 'Preview Image')
|
||||
?.inputs[0].link
|
||||
if (!linkId) return 'failed to resolve link id'
|
||||
|
||||
const rerouteId = graph.getLink(linkId)?.parentId
|
||||
if (!rerouteId) return 'failed to resolve reroute id'
|
||||
|
||||
return !graph.reroutes.has(rerouteId) && 'reroute does not exist'
|
||||
}),
|
||||
'old link is disconnected, reroute is part of new connection'
|
||||
)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
@@ -234,8 +234,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
await comfyPage.page
|
||||
.locator('[data-node-id] img')
|
||||
|
||||
@@ -14,8 +14,7 @@ const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Image')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 94 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 }> => {
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
return box
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
@@ -84,27 +84,29 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.idleFrames(2)
|
||||
}
|
||||
|
||||
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 })
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(initialHeaderPos, newHeaderPos)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
})
|
||||
|
||||
test('should not move node when pointer moves less than drag threshold', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
comfyPage
|
||||
}) => {
|
||||
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
|
||||
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
|
||||
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()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
@@ -293,12 +295,14 @@ 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 headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
|
||||
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 104 KiB |
@@ -42,10 +42,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await expect(pinIndicator2).toBeHidden()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({
|
||||
comfyMouse,
|
||||
comfyPage
|
||||
}) => {
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
@@ -53,7 +50,10 @@ 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 comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
await expect
|
||||
@@ -64,7 +64,11 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
|
||||
// Try to drag the node again
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is dragged
|
||||
await expect
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
@@ -8,32 +8,25 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
let legacyNodeId = toNodeId(10)
|
||||
|
||||
await test.step('setup', async () => {
|
||||
const legacyNode = await comfyPage.nodeOps.addNode(
|
||||
'DevToolsNodeWithLegacyWidget',
|
||||
undefined,
|
||||
{
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
)
|
||||
legacyNodeId = legacyNode.id
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
[String(legacyNodeId), 'legacy_widget']
|
||||
])
|
||||
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
|
||||
})
|
||||
|
||||
const getWidth = async () =>
|
||||
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
|
||||
?.width ?? 0
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
|
||||
toNodeId(10)
|
||||
)
|
||||
|
||||
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(legacyNodeId)
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
|
||||
const legacyWidgetRef = await nodeRef.getWidget(0)
|
||||
expect(await legacyWidgetRef.getValue()).toBe(0)
|
||||
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
|
||||
@@ -43,8 +36,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,43 +3,31 @@ 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 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)
|
||||
})
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
|
||||
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`)
|
||||
await expect(loadCheckpointNode).toHaveCount(1)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.addWidget('text', 'extra_widget_a', '', () => {})
|
||||
}, 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`)
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.addWidget('text', 'extra_widget_b', '', () => {})
|
||||
}, 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`)
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.addWidget('text', 'extra_widget_c', '', () => {})
|
||||
}, nodeId)
|
||||
await expect(widgets).toHaveCount(4)
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('Should hide removed widgets', async ({ comfyPage }) => {
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
|
||||
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
// 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.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
@@ -21,22 +21,6 @@ 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.
|
||||
@@ -131,15 +115,6 @@ 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).
|
||||
@@ -165,13 +140,6 @@ 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`) |
|
||||
@@ -180,13 +148,6 @@ 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`) |
|
||||
@@ -310,9 +271,6 @@ 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 |
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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),
|
||||
[ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## 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`. 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.
|
||||
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.
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
|
||||
|
||||
@@ -66,7 +66,6 @@ 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
|
||||
@@ -74,14 +73,12 @@ sequenceDiagram
|
||||
|
||||
Caller->>CS: removeNode(nodeId)
|
||||
|
||||
CS->>LKS: read node links (incoming + outgoing)
|
||||
LKS-->>CS: link topologies
|
||||
CS->>LS: read node links (incoming + outgoing)
|
||||
LS-->>CS: linkIds
|
||||
|
||||
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
|
||||
loop each linkId
|
||||
CS->>LM: deleteLink(linkId)
|
||||
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
|
||||
end
|
||||
|
||||
loop each widget on node
|
||||
@@ -96,15 +93,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 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 |
|
||||
| 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 |
|
||||
|
||||
## 2. Serialization
|
||||
|
||||
@@ -226,7 +223,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
opt has subgraph definitions
|
||||
G->>G: deduplicateSubgraphNodeIds() + deduplicateSubgraphRerouteIds()
|
||||
G->>G: deduplicateSubgraphNodeIds()
|
||||
loop each subgraph (topological order)
|
||||
G->>G: createSubgraph(data)
|
||||
end
|
||||
@@ -253,7 +250,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
G->>G: add floating links
|
||||
G->>G: prune reroutes with derived totalLinks === 0
|
||||
G->>G: validate reroutes
|
||||
G->>G: _removeDuplicateLinks()
|
||||
|
||||
loop each serialized group
|
||||
@@ -265,8 +262,6 @@ 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
|
||||
@@ -537,7 +532,7 @@ sequenceDiagram
|
||||
participant G as LGraph
|
||||
participant L as LLink
|
||||
participant R as Reroute
|
||||
participant LKS as linkStore
|
||||
participant LS as LayoutStore
|
||||
|
||||
Caller->>N1: connectSlots(output, targetNode, input)
|
||||
|
||||
@@ -550,24 +545,24 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
N1->>L: new LLink(++lastLinkId, type, ...)
|
||||
N1->>G: graph._addLink(link)
|
||||
G->>LKS: register topology (keyed by target input slot)
|
||||
Note over G,LKS: chokepoint: _links.set + linkStore registration
|
||||
N1->>G: _links.set(link.id, link)
|
||||
N1->>LS: layoutMutations.createLink()
|
||||
|
||||
N1->>N1: output.links.push(link.id)
|
||||
N1->>N2: input.link = link.id
|
||||
|
||||
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
|
||||
loop each reroute in path
|
||||
N1->>R: reroute.linkIds.add(link.id)
|
||||
end
|
||||
|
||||
N1->>G: incrementVersion()
|
||||
N1->>G: _version++
|
||||
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 (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.
|
||||
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.
|
||||
|
||||
### ECS Flow
|
||||
|
||||
@@ -575,28 +570,29 @@ Problems: the source node orchestrates everything — it reaches into the graph'
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant LKS as linkStore
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
|
||||
Caller->>CS: connect(outputSlot, inputSlot)
|
||||
|
||||
CS->>LKS: getInputSlotLink(graphId, targetNodeId, targetSlot)
|
||||
CS->>LS: read input slot link
|
||||
opt already connected
|
||||
CS->>LKS: unregister existing topology
|
||||
CS->>LM: deleteLink(existingLinkId)
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## 7. Copy / Paste
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ 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, link topology, reroute
|
||||
> chains). The earlier "single unified
|
||||
> outputs, subgraph navigation, preview exposure). 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
|
||||
@@ -114,25 +113,14 @@ 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` |
|
||||
| `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).
|
||||
| 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` |
|
||||
|
||||
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
|
||||
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
|
||||
@@ -272,14 +260,6 @@ 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.
|
||||
|
||||
---
|
||||
@@ -331,12 +311,6 @@ 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.
|
||||
|
||||
@@ -622,15 +596,13 @@ 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'` |
|
||||
| `linkStore` | `` `${targetNodeId}:${targetSlot}` `` (target input slot), root-graph-scoped buckets |
|
||||
| `rerouteStore` | `RerouteId`, root-graph-scoped buckets |
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
@@ -657,8 +629,7 @@ 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 + 7 others shipped
|
||||
(PR 12617; linkStore PR 13436; rerouteStore PR 13449)
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
|
||||
|
||||
Phase 2a (Position via layoutStore) ─┐── depends on 1c
|
||||
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
|
||||
|
||||
@@ -18,12 +18,7 @@ Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
LayoutStore["layoutStore (Y.js CRDT)
|
||||
nodeId / linkId / rerouteId → geometry"]
|
||||
LinkStore["linkStore
|
||||
rootGraphId → targetNodeId:targetSlot
|
||||
→ LinkTopology"]
|
||||
RerouteStore["rerouteStore
|
||||
rootGraphId → RerouteId → RerouteChain"]
|
||||
nodeId / linkId / rerouteId → layout"]
|
||||
NodeOutputStore["nodeOutputStore
|
||||
Map<nodeLocatorId, outputs>"]
|
||||
SubgraphNavStore["subgraphNavigationStore
|
||||
@@ -44,8 +39,7 @@ preview exposure state"]
|
||||
|
||||
RS -->|reads| Stores
|
||||
SS -->|reads/writes| Stores
|
||||
CS -->|reads/writes| LinkStore
|
||||
CS -->|reads/writes| RerouteStore
|
||||
CS -->|reads/writes| LayoutStore
|
||||
LS -->|reads/writes| LayoutStore
|
||||
ES -->|reads| NodeOutputStore
|
||||
VS -->|reads/writes| LayoutStore
|
||||
@@ -69,31 +63,20 @@ 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, rerouteStore| W
|
||||
TIS -->|linkStore| W
|
||||
RID -->|layoutStore| W
|
||||
```
|
||||
|
||||
`WidgetId = graphId:nodeId:name` is itself a branded string (see
|
||||
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -194,15 +177,14 @@ target_id, target_slot, type"]
|
||||
B5["resolve()"]
|
||||
end
|
||||
|
||||
subgraph After["target-slot-keyed topology (linkStore) + unextracted state"]
|
||||
subgraph After["linkId-keyed components (layoutStore)"]
|
||||
direction TB
|
||||
A1["LinkTopology — SHIPPED
|
||||
{ id, originNodeId, originSlot,
|
||||
targetNodeId, targetSlot, type, parentId? }
|
||||
keyed by targetNodeId:targetSlot"]
|
||||
A2["LinkVisual — not yet extracted
|
||||
A1["LinkEndpoints
|
||||
{ originId, originSlot,
|
||||
targetId, targetSlot, type }"]
|
||||
A2["LinkVisual
|
||||
{ color, path, centerPos }"]
|
||||
A3["LinkState — not yet extracted
|
||||
A3["LinkState
|
||||
{ dragging, data }"]
|
||||
end
|
||||
|
||||
@@ -216,26 +198,6 @@ keyed by targetNodeId:targetSlot"]
|
||||
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
|
||||
@@ -286,8 +248,8 @@ graph TD
|
||||
direction TB
|
||||
CS["ConnectivitySystem
|
||||
Manages link/slot mutations.
|
||||
Writes: LinkTopology (shipped),
|
||||
SlotConnection (future), Connectivity"]
|
||||
Writes: LinkEndpoints, SlotConnection,
|
||||
Connectivity"]
|
||||
VS["VersionSystem
|
||||
Centralizes change tracking.
|
||||
Replaces 15+ scattered _version++.
|
||||
@@ -350,11 +312,9 @@ graph LR
|
||||
Exe["Execution"]
|
||||
Props["Properties"]
|
||||
WC["WidgetContainer"]
|
||||
LE["LinkTopology
|
||||
(linkStore — shipped)"]
|
||||
LE["LinkEndpoints"]
|
||||
LV["LinkVisual"]
|
||||
SC["SlotConnection
|
||||
(output side — future)"]
|
||||
SC["SlotConnection"]
|
||||
SV["SlotVisual"]
|
||||
WVal["WidgetValue"]
|
||||
WL["WidgetLayout"]
|
||||
@@ -373,7 +333,7 @@ graph LR
|
||||
LS -.->|read| WC
|
||||
|
||||
CS -->|write| LE
|
||||
CS -.->|future write| SC
|
||||
CS -->|write| SC
|
||||
CS -->|write| Con
|
||||
|
||||
ES -.->|read| Con
|
||||
@@ -389,14 +349,6 @@ 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 (derived, rerouteStore)"| Link
|
||||
Reroute -.->|"linkIds"| Link
|
||||
Reroute -.->|"parentId"| Reroute
|
||||
Group -.->|"_children Set"| Node
|
||||
Group -.->|"_children Set"| Reroute
|
||||
@@ -103,14 +103,10 @@ type: ISlotType"]
|
||||
Link -.->|"parentId"| R1["Reroute A"]
|
||||
R1 -.->|"parentId"| R2["Reroute B"]
|
||||
|
||||
R1 -.-|"linkIds (derived)"| Link
|
||||
R2 -.-|"linkIds (derived)"| Link
|
||||
R1 -.-|"linkIds Set"| Link
|
||||
R2 -.-|"linkIds Set"| 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
|
||||
@@ -146,20 +142,14 @@ graph TD
|
||||
```mermaid
|
||||
graph LR
|
||||
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
|
||||
origin or target = UNASSIGNED_NODE_ID"]
|
||||
origin_id=-1 or target_id=-1"]
|
||||
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 (derived)"| FL
|
||||
Reroute -.-|"floatingLinkIds Set"| FL
|
||||
FL -->|"on drop"| Permanent["Permanent LLink
|
||||
(graph._links + linkStore target index)"]
|
||||
(registered in graph._links)"]
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -264,7 +254,7 @@ stateDiagram-v2
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: node.connect() or connectSlots()
|
||||
Created --> Registered: graph._addLink(link)
|
||||
Created --> Registered: graph._links.set(id, link)
|
||||
|
||||
state Registered {
|
||||
[*] --> Active
|
||||
@@ -278,18 +268,12 @@ 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 and
|
||||
unregistered from linkStore
|
||||
(via _removeLink or link.disconnect).
|
||||
Link geometry dropped from layoutStore.
|
||||
Removed from graph._links.
|
||||
Orphaned reroutes cleaned up.
|
||||
graph._version incremented.
|
||||
end note
|
||||
@@ -378,10 +362,6 @@ graph TD
|
||||
WVS["WidgetValueStore
|
||||
(Pinia)"]
|
||||
PES["PreviewExposureStore
|
||||
(Pinia)"]
|
||||
LKS["LinkStore
|
||||
(Pinia)"]
|
||||
RRS["RerouteStore
|
||||
(Pinia)"]
|
||||
LM["LayoutMutations
|
||||
(composable)"]
|
||||
@@ -403,20 +383,10 @@ lastRerouteId, lastGroupId)"]
|
||||
SGNode -->|"host-scoped preview exposures"| PES
|
||||
PES -.->|"keyed by host node locator"| SGNode
|
||||
|
||||
%% 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)
|
||||
%% LayoutMutations
|
||||
Node -->|"pos/size setter"| LM
|
||||
Reroute -->|"move()"| LM
|
||||
Link -->|"disconnect(): drop link geometry"| LM
|
||||
Link -->|"connectSlots()/disconnect()"| LM
|
||||
Graph -->|"add()/remove()"| LM
|
||||
|
||||
%% Graph state
|
||||
|
||||
@@ -168,24 +168,12 @@ 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:
|
||||
|
||||
- `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
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `Reroute.ts` — same pattern at module scope
|
||||
- `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
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# 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` and `updateEndpoint` are **identity-checked** (`toRaw`
|
||||
comparison): only the registered topology can vacate or re-key its
|
||||
slot.
|
||||
- `updateEndpoint` re-keys atomically — displace, patch fields through
|
||||
a reactive wrapper, re-place under the new key — and returns
|
||||
`undefined` when the new target is already occupied. 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 `input.link` / `output.links`
|
||||
slot mirrors remain the litegraph-native representation un-migrated
|
||||
consumers read; extracting them is the `SlotConnection` component work
|
||||
in the [ECS migration plan](ecs-migration-plan.md), not part of this
|
||||
store.
|
||||
@@ -6,30 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Eight dedicated stores extract entity state out of class instances into focused,
|
||||
Six 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 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).
|
||||
| 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 |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -159,37 +149,28 @@ The most architecturally advanced extraction — uses Y.js CRDTs for collaborati
|
||||
|
||||
```
|
||||
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → { id, position }
|
||||
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
|
||||
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
|
||||
```
|
||||
|
||||
**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(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.)
|
||||
- `moveNode(graphId, nodeId, pos)`
|
||||
- `resizeNode(graphId, nodeId, size)`
|
||||
- `setNodeZIndex(graphId, nodeId, zIndex)`
|
||||
- `createLink(graphId, linkId, ...)`
|
||||
- `removeLink(graphId, linkId)`
|
||||
- `moveReroute(graphId, rerouteId, pos)`
|
||||
|
||||
### The Scattered Access Problem
|
||||
|
||||
This composable is called at **module scope** in domain objects:
|
||||
|
||||
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()`
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()`
|
||||
- `Reroute.ts` — same pattern
|
||||
- `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.
|
||||
|
||||
@@ -253,14 +234,12 @@ 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 |
|
||||
| LinkStore | `` `${targetNodeId}:${targetSlot}` `` (root-scoped buckets) | Composite string | No |
|
||||
| RerouteStore | `RerouteId` (root-scoped buckets) | branded number | Yes (`RerouteId`) |
|
||||
| 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 |
|
||||
|
||||
`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
|
||||
@@ -307,26 +286,22 @@ graph TD
|
||||
|
||||
subgraph Link["LLink"]
|
||||
L_ext["Extracted:
|
||||
- id, endpoints, type, parentId → LinkStore
|
||||
(LLink._state IS the store entry;
|
||||
fields are accessors over it)
|
||||
- segment geometry → LayoutStore"]
|
||||
- layout data → LayoutStore"]
|
||||
L_rem["Remains on class:
|
||||
- color, path, _pos, _centreAngle
|
||||
- origin_id, target_id
|
||||
- origin_slot, target_slot
|
||||
- type, color, path
|
||||
- data, _dragging
|
||||
- disconnect(), resolve()"]
|
||||
end
|
||||
|
||||
subgraph Reroute["Reroute"]
|
||||
R_ext["Extracted:
|
||||
- pos → LayoutStore (partial mirror;
|
||||
posInternal still truth)
|
||||
- parentId, floating → RerouteStore
|
||||
- linkIds, floatingLinkIds → derived
|
||||
from links' parentId chains"]
|
||||
- pos → LayoutStore"]
|
||||
R_rem["Remains on class:
|
||||
- posInternal (position truth)
|
||||
- colour, draw()
|
||||
- parentId, linkIds
|
||||
- floatingLinkIds
|
||||
- color, draw()
|
||||
- findSourceOutput()"]
|
||||
end
|
||||
|
||||
@@ -371,19 +346,15 @@ 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** | 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`.
|
||||
| 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 |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
@@ -391,11 +362,8 @@ 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** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
|
||||
(`input.link`/`output.links`) and visual state remain
|
||||
3. **Link** — small component set, high coupling pain
|
||||
4. **Slot** — no extraction yet, but small and self-contained
|
||||
(`SlotConnection` input side now answerable via LinkStore)
|
||||
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
|
||||
ownership and visual remain
|
||||
5. **Reroute** — partially extracted, moderate complexity
|
||||
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)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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,29 +41,6 @@ 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
|
||||
@@ -101,8 +78,6 @@ 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,6 +53,7 @@
|
||||
"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,4 +1,10 @@
|
||||
import { cleanupSVG, importDirectorySync, runSVGO } from '@iconify/tools'
|
||||
import {
|
||||
cleanupSVG,
|
||||
importDirectorySync,
|
||||
isEmptyColor,
|
||||
parseColors,
|
||||
runSVGO
|
||||
} from '@iconify/tools'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export const COMFY_ICON_PREFIX = 'comfy'
|
||||
@@ -7,6 +13,13 @@ 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)
|
||||
@@ -19,6 +32,18 @@ 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 {
|
||||
|
||||
23
packages/design-system/src/css/iconifyDynamicPlugin.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
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.ts";
|
||||
@plugin "./iconifyDynamicPlugin.js";
|
||||
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@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}]");
|
||||
@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}]");
|
||||
|
||||
@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-200);
|
||||
--node-component-header-surface: var(--color-smoke-400);
|
||||
--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-700);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--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: var(--comfy-textarea-font-size);
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.comfy-markdown-content h1 {
|
||||
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 22px; /* text-[22px] */
|
||||
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: calc(18 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 18px; /* text-[18px] */
|
||||
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: calc(16 / 14 * var(--comfy-textarea-font-size));
|
||||
font-size: 16px; /* text-[16px] */
|
||||
font-weight: 700; /* font-bold */
|
||||
margin-top: 2rem; /* mt-8 */
|
||||
margin-bottom: 1rem; /* mb-4 */
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.30005 7L7.65005 9.5M12 12L12 22M12 12L16.35 9.5M12 12L7.65005 9.5M20.7001 7L16.35 9.5M16.35 9.5V19.8156M16.35 9.5L7.5 4.2699M7.5 4.2699L11 2.2699C11.304 2.09437 11.6489 2.00195 12 2.00195C12.3511 2.00195 12.696 2.09437 13 2.2699L16.5 4.2699L20 6.2699C20.3037 6.44526 20.556 6.69742 20.7315 7.00106C20.9071 7.30471 20.9996 7.64918 21 7.9999V15.9999C20.9996 16.3506 20.9071 16.6951 20.7315 16.9987C20.556 17.3024 20.3037 17.5545 20 17.7299L16.35 19.8156L13 21.7299C12.696 21.9054 12.3511 21.9979 12 21.9979C11.6489 21.9979 11.304 21.9054 11 21.7299L7.65005 19.8156L4 17.7299C3.69626 17.5545 3.44398 17.3024 3.26846 16.9987C3.09294 16.6951 3.00036 16.3506 3 15.9999V7.9999C3.00036 7.64918 3.09294 7.30471 3.26846 7.00106C3.44398 6.69742 3.69626 6.44526 4 6.2699L7.5 4.2699ZM7.65005 9.5V19.8156M7.65005 9.5L16.5 4.2699" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 1014 B |
@@ -164,34 +164,6 @@ packages/design-system/src/icons/
|
||||
|
||||
No imports needed - icons are auto-discovered!
|
||||
|
||||
## Render Mode: Mask vs Image
|
||||
|
||||
The default render mode is decided by the SVG's own colors:
|
||||
|
||||
- **Mask** — SVG with `currentColor`. The shape is displayed as a background
|
||||
mask, so it adapts to the theme via `text-*` classes.
|
||||
- **Image** — the SVG uses concrete colors (e.g. `#d97757`). It is embedded
|
||||
as an image, preserving its own colors; `text-*` has no effect. Use this for
|
||||
brand logos that must keep their palette.
|
||||
|
||||
```
|
||||
workflow.svg fill="currentColor" -> mask (themeable)
|
||||
claude.svg fill="#d97757" -> image (brand color preserved)
|
||||
```
|
||||
|
||||
### Forcing a mode at usage time
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i class="icon-[comfy--openai]" />
|
||||
<!-- default from SVG colors -->
|
||||
<i class="icon-img-[comfy--openai]" />
|
||||
<!-- force image, own colors -->
|
||||
<i class="icon-mask-[comfy--luma]" />
|
||||
<!-- force themeable mask -->
|
||||
</template>
|
||||
```
|
||||
|
||||
## Icon Guidelines
|
||||
|
||||
### Naming Conventions
|
||||
|
||||