Compare commits
16 Commits
feature/sh
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2393be48 | ||
|
|
a451a90868 | ||
|
|
be102899d7 | ||
|
|
abd1a6f10a | ||
|
|
c16f10b49e | ||
|
|
64253de713 | ||
|
|
b4ae6344d7 | ||
|
|
feaa4ce82f | ||
|
|
f5d059b720 | ||
|
|
e2c670bbc3 | ||
|
|
dfcb336499 | ||
|
|
caabebe145 | ||
|
|
7d3d8ce63f | ||
|
|
506e33e7dd | ||
|
|
7376402fc6 | ||
|
|
be38f14619 |
@@ -33,15 +33,15 @@ Flag:
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
### Dedicated Stores and Data/Behavior Separation
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
|
||||
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -88,9 +88,9 @@ jobs:
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
'assets/images/*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
@@ -121,7 +121,8 @@ 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
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
5
.github/workflows/pr-backport.yaml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist a token with `workflow` scope so the backport push can
|
||||
# include changes to .github/workflows/**. The default GITHUB_TOKEN
|
||||
# is refused by GitHub when a push creates/updates workflow files,
|
||||
# which silently aborted the whole job (see PR #12804 backport).
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,8 @@ module.exports = defineConfig({
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
'fa',
|
||||
'he'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
@@ -37,5 +38,11 @@ module.exports = defineConfig({
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
|
||||
IMPORTANT Hebrew Translation Guidelines:
|
||||
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
|
||||
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
|
||||
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
|
||||
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
|
||||
`
|
||||
})
|
||||
|
||||
@@ -179,6 +179,9 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
23. Favor pure functions (especially testable ones)
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
26. Do not add alias helpers whose implementation is just a single-line call to another function
|
||||
- Bad: `function id(value) { return nodeId(value) }`
|
||||
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
|
||||
|
||||
## Design Standards
|
||||
|
||||
@@ -246,7 +249,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
231
apps/website/e2e/launches.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { drops } from '../src/data/drops'
|
||||
import type { Locale } from '../src/i18n/translations'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH_EN = '/launches'
|
||||
const PATH_ZH = '/zh-CN/launches'
|
||||
const CLOUD_URL = 'https://cloud.comfy.org'
|
||||
|
||||
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
|
||||
[PATH_EN, 'en'],
|
||||
[PATH_ZH, 'zh-CN']
|
||||
]
|
||||
|
||||
function heroSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.hero.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ctaSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function dropsSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.section.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Launches landing — desktop @smoke', () => {
|
||||
test('renders the configured title at /launches', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page).toHaveTitle(t('launches.page.title', 'en'))
|
||||
})
|
||||
|
||||
test('renders the localized title at /zh-CN/launches', async ({ page }) => {
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page).toHaveTitle(t('launches.page.title', 'zh-CN'))
|
||||
})
|
||||
|
||||
test('is indexable at both locales', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('hero h1 renders the localized title in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.hero.title', 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.hero.title', 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('hero primary CTA links to /download per locale', async ({ page }) => {
|
||||
for (const [path, locale, expectedHref] of [
|
||||
[PATH_EN, 'en', '/download'],
|
||||
[PATH_ZH, 'zh-CN', '/zh-CN/download']
|
||||
] as const) {
|
||||
await page.goto(path)
|
||||
const primary = heroSection(page, locale).getByRole('link', {
|
||||
name: t('launches.hero.primary', locale)
|
||||
})
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', expectedHref)
|
||||
}
|
||||
})
|
||||
|
||||
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const secondary = heroSection(page, locale).getByRole('link', {
|
||||
name: t('launches.hero.secondary', locale)
|
||||
})
|
||||
await expect(secondary).toBeVisible()
|
||||
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
|
||||
await expect(secondary).toHaveAttribute('target', '_blank')
|
||||
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('closing CTA shows heading and both action buttons in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const section = ctaSection(page, locale)
|
||||
await expect(
|
||||
section.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const primary = section.getByRole('link', {
|
||||
name: t('launches.cta.primary', locale)
|
||||
})
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
|
||||
await expect(primary).toHaveAttribute('target', '_blank')
|
||||
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const secondary = section.getByRole('link', {
|
||||
name: t('launches.cta.secondary', locale)
|
||||
})
|
||||
await expect(secondary).toBeVisible()
|
||||
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
|
||||
await expect(secondary).toHaveAttribute('target', '_blank')
|
||||
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const section = dropsSection(page, locale)
|
||||
|
||||
await expect(
|
||||
section.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.section.title', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const cards = section.locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
for (const [i, drop] of drops.entries()) {
|
||||
const card = cards.nth(i)
|
||||
await expect(card).toContainText(drop.title[locale])
|
||||
const explore = card.getByRole('link', {
|
||||
name: drop.cta.label[locale]
|
||||
})
|
||||
await expect(explore).toBeVisible()
|
||||
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop: first 4 drop cards are wider than cards 5+', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
|
||||
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
|
||||
return firstWidth - fifthWidth
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Launches landing — mobile @mobile', () => {
|
||||
test('drops grid stacks in a single column at mobile width', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
expect(viewport, 'viewport size').not.toBeNull()
|
||||
|
||||
await expect
|
||||
.poll(async () => (await cards.nth(0).boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(viewport!.width * 0.7)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const firstBox = await cards.nth(0).boundingBox()
|
||||
const secondBox = await cards.nth(1).boundingBox()
|
||||
if (!firstBox || !secondBox) return false
|
||||
return secondBox.y >= firstBox.y + firstBox.height
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('closing CTA heading stays within viewport width', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
const heading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', 'en')
|
||||
})
|
||||
await heading.scrollIntoViewIfNeeded()
|
||||
await expect(heading).toBeVisible()
|
||||
|
||||
const box = await heading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
const viewport = page.viewportSize()
|
||||
expect(viewport, 'viewport size').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
@@ -12,10 +16,11 @@ type TermsLink = {
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
secondaryCta?: Cta
|
||||
termsLink?: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -24,23 +29,37 @@ defineProps<{
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="resolveRel(primaryCta)"
|
||||
variant="default"
|
||||
size="lg"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="resolveRel(secondaryCta)"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="termsLink"
|
||||
:href="termsLink.href"
|
||||
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
|
||||
166
apps/website/src/components/blocks/HeroLivestream01.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type Visual =
|
||||
| {
|
||||
type: 'image'
|
||||
src: string
|
||||
alt: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
alt: string
|
||||
poster?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const {
|
||||
visual,
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
youtubeVideoId,
|
||||
startDateTime,
|
||||
endDateTime
|
||||
} = defineProps<{
|
||||
visual?: Visual
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
youtubeVideoId: string
|
||||
startDateTime: string
|
||||
endDateTime: string
|
||||
}>()
|
||||
|
||||
const embedUrl = computed(
|
||||
() =>
|
||||
`https://www.youtube-nocookie.com/embed/${youtubeVideoId}?autoplay=1&mute=1&rel=0`
|
||||
)
|
||||
|
||||
// Keep SSR/initial paint deterministic on the logo and only flip to the embed
|
||||
// after client hydration — avoids a build-time `now` leaking into the markup.
|
||||
const mounted = ref(false)
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
})
|
||||
|
||||
const now = useNow({ interval: 30_000 })
|
||||
const startMs = computed(() => new Date(startDateTime).getTime())
|
||||
const endMs = computed(() => new Date(endDateTime).getTime())
|
||||
|
||||
const isLive = computed(
|
||||
() =>
|
||||
mounted.value &&
|
||||
now.value.getTime() >= startMs.value &&
|
||||
now.value.getTime() < endMs.value
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<div
|
||||
v-if="isLive"
|
||||
class="mb-10 aspect-video w-full overflow-hidden rounded-2xl lg:mb-12"
|
||||
>
|
||||
<iframe
|
||||
:src="embedUrl"
|
||||
:title="title"
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
<slot v-else name="visual">
|
||||
<img
|
||||
v-if="visual?.type === 'image'"
|
||||
:src="visual.src"
|
||||
:alt="visual.alt"
|
||||
:width="visual.width"
|
||||
:height="visual.height"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-lg"
|
||||
/>
|
||||
<video
|
||||
v-else-if="visual?.type === 'video'"
|
||||
:src="visual.src"
|
||||
:poster="visual.poster"
|
||||
:aria-label="visual.alt"
|
||||
:width="visual.width"
|
||||
:height="visual.height"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-2xl"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<p
|
||||
v-if="eyebrow"
|
||||
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-2xl text-base text-primary-comfy-canvas/70 lg:text-lg"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="resolveRel(primaryCta)"
|
||||
size="lg"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="resolveRel(secondaryCta)"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BrandButtonVariants } from './brandButton.variants'
|
||||
import { brandButtonVariants } from './brandButton.variants'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
const props = defineProps<{
|
||||
href?: string
|
||||
@@ -16,9 +17,8 @@ const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
const resolvedRel = computed(() =>
|
||||
resolveRel({ rel: props.rel, target: props.target })
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{ label: t('nav.launches', locale), href: routes.launches },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
:class="cn(badgeVariants({ size, variant }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
|
||||
@@ -4,15 +4,16 @@ import { cva } from 'cva'
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,7 +8,8 @@ export const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
sm: 'h-8 px-4 py-2 text-xs md:text-sm',
|
||||
default: 'h-10 px-6 py-2.5 text-xs md:text-sm',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
@@ -17,6 +18,8 @@ export const buttonVariants = cva(
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
underlineLink:
|
||||
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
22
apps/website/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 text-primary-warm-white rounded-4.5xl flex flex-col gap-6 shadow-sm',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-content" :class="cn('px-6', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
19
apps/website/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-description"
|
||||
:class="
|
||||
cn('line-clamp-3 text-base text-primary-comfy-canvas/70', className)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-footer" :class="cn('flex items-center', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-header" :class="cn('flex flex-col gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
apps/website/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-title"
|
||||
:class="
|
||||
cn(
|
||||
'text-xl leading-none font-medium text-primary-comfy-canvas md:text-2xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,6 +22,12 @@ interface HeroLogoConfig {
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
svgMarkup: string
|
||||
fitAxis: 'width' | 'height'
|
||||
targetSize: number
|
||||
respectReducedMotion: boolean
|
||||
baseUrl: string
|
||||
fadeInDurationMs: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
@@ -34,19 +40,25 @@ const DEFAULTS: HeroLogoConfig = {
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
slideDuration: 0.4,
|
||||
svgMarkup: SVG_MARKUP,
|
||||
fitAxis: 'height',
|
||||
targetSize: 3,
|
||||
respectReducedMotion: true,
|
||||
baseUrl: BASE_URL,
|
||||
fadeInDurationMs: 0
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
function buildImageUrls(baseUrl: string): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
return `${baseUrl}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
function parseShapes(markup: string): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const svgData = loader.parse(markup)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
@@ -85,7 +97,8 @@ export function useHeroLogo(
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
if (!container || (cfg.respectReducedMotion && prefersReducedMotion()))
|
||||
return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
@@ -102,6 +115,9 @@ export function useHeroLogo(
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
if (cfg.fadeInDurationMs > 0) {
|
||||
renderer.domElement.style.transition = `opacity ${cfg.fadeInDurationMs}ms ease`
|
||||
}
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
@@ -126,24 +142,36 @@ export function useHeroLogo(
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const shapes = parseShapes(cfg.svgMarkup)
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const bb = tempGeo.boundingBox
|
||||
if (!bb) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
const fitExtent =
|
||||
cfg.fitAxis === 'width' ? bb.max.x - bb.min.x : bb.max.y - bb.min.y
|
||||
if (fitExtent <= 0) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const scaleFactor = cfg.targetSize / fitExtent
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const urls = buildImageUrls(cfg.baseUrl)
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
void loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const baseRoutes = {
|
||||
cloudEnterprise: '/cloud/enterprise',
|
||||
api: '/api',
|
||||
gallery: '/gallery',
|
||||
launches: '/launches',
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
@@ -59,6 +60,7 @@ export const externalLinks = {
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
|
||||
248
apps/website/src/data/drops.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
// Image URLs are placeholders at media.comfy.org/website/drops/<id>.png —
|
||||
// asset uploads and native zh-CN review are pending follow-ups (see
|
||||
// apps/website/.scratch/drops-page/PRD.md).
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
type DropMedia =
|
||||
| { type: 'image'; src: string; alt: LocalizedText }
|
||||
| { type: 'video'; src: string; alt: LocalizedText; poster?: string }
|
||||
|
||||
export type Drop = {
|
||||
id: string
|
||||
badge?: LocalizedText
|
||||
category: LocalizedText
|
||||
media: DropMedia
|
||||
title: LocalizedText
|
||||
description: LocalizedText
|
||||
cta: { label: LocalizedText; href: LocalizedText }
|
||||
}
|
||||
|
||||
const EXPLORE: LocalizedText = { en: 'EXPLORE', 'zh-CN': '探索' }
|
||||
const PLATFORM: LocalizedText = { en: 'Platform', 'zh-CN': '平台' }
|
||||
const CLOUD: LocalizedText = { en: 'Cloud', 'zh-CN': '云端' }
|
||||
const COMMUNITY: LocalizedText = { en: 'Community', 'zh-CN': '社区' }
|
||||
const DEVELOPER: LocalizedText = { en: 'Developer', 'zh-CN': '开发者' }
|
||||
const MODELS_AND_NODES: LocalizedText = {
|
||||
en: 'Models & Nodes',
|
||||
'zh-CN': '模型与节点'
|
||||
}
|
||||
const NEW_BADGE: LocalizedText = { en: 'NEW', 'zh-CN': '新' }
|
||||
const FEATURED_BADGE: LocalizedText = { en: 'FEATURED', 'zh-CN': '精选' }
|
||||
|
||||
function imageFor(fileName: string, alt: LocalizedText): DropMedia {
|
||||
return {
|
||||
type: 'image',
|
||||
src: `https://media.comfy.org/website/drops/${fileName}`,
|
||||
alt
|
||||
}
|
||||
}
|
||||
|
||||
function videoFor(
|
||||
fileName: string,
|
||||
alt: LocalizedText,
|
||||
poster?: string
|
||||
): DropMedia {
|
||||
return {
|
||||
type: 'video',
|
||||
src: `https://media.comfy.org/website/drops/${fileName}`,
|
||||
alt,
|
||||
...(poster && {
|
||||
poster: `https://media.comfy.org/website/drops/${poster}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const drops: readonly Drop[] = [
|
||||
{
|
||||
id: 'desktop-client',
|
||||
badge: NEW_BADGE,
|
||||
category: PLATFORM,
|
||||
media: imageFor('Drops_2x2card_Desktop.jpg', {
|
||||
en: 'New Desktop Client',
|
||||
'zh-CN': '新桌面客户端'
|
||||
}),
|
||||
title: { en: 'New Desktop Client', 'zh-CN': '新桌面客户端' },
|
||||
description: {
|
||||
en: 'A faster, redesigned desktop app for ComfyUI — one-click install and managed updates.',
|
||||
'zh-CN': '更快、重新设计的 ComfyUI 桌面应用程序 — 一键安装与受管更新。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
category: PLATFORM,
|
||||
media: videoFor('Drops_2x2card_APP.mp4', {
|
||||
en: 'App Mode',
|
||||
'zh-CN': 'App 模式'
|
||||
}),
|
||||
title: { en: 'App Mode', 'zh-CN': 'App 模式' },
|
||||
description: {
|
||||
en: 'A simplified view of your workflows. Flip back to the node graph anytime to go deeper.',
|
||||
'zh-CN': '工作流的简化视图。随时切换回节点图视图以深入了解。'
|
||||
},
|
||||
// TODO: no destination page yet — link out when App Mode lands.
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: {
|
||||
en: 'https://docs.comfy.org/interface/app-mode',
|
||||
'zh-CN': 'https://docs.comfy.org/zh/interface/app-mode'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-api',
|
||||
badge: NEW_BADGE,
|
||||
category: DEVELOPER,
|
||||
media: imageFor('Drops_2x2card_API.jpg', {
|
||||
en: 'Comfy API',
|
||||
'zh-CN': 'Comfy API'
|
||||
}),
|
||||
title: { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
description: {
|
||||
en: 'Turn any workflow into a production endpoint. Automate generation and scale to thousands of outputs.',
|
||||
'zh-CN': '将任意工作流变成生产端点。自动化生成并扩展到数千个输出。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
media: imageFor('Drops_3x3card_Comm Workflows.jpg', {
|
||||
en: 'Community Workflows',
|
||||
'zh-CN': '社区工作流'
|
||||
}),
|
||||
title: {
|
||||
en: 'Community Workflows',
|
||||
'zh-CN': '社区工作流'
|
||||
},
|
||||
description: {
|
||||
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template.',
|
||||
'zh-CN': '浏览和混搭数千个社区共享的工作流。从经过验证的模板开始。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.workflows, 'zh-CN': externalLinks.workflows }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supported-models',
|
||||
category: MODELS_AND_NODES,
|
||||
media: imageFor('Drops_Supported models.jpg', {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
}),
|
||||
title: { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
description: {
|
||||
en: 'Run the latest open and partner models — every checkpoint, LoRA, and ControlNet, ready to use in your graph.',
|
||||
'zh-CN':
|
||||
'运行最新的开源和合作伙伴模型 — 每个 checkpoint、LoRA 和 ControlNet 都可直接在工作流中使用。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/p/supported-models', 'zh-CN': '/zh-CN/p/supported-models' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supported-nodes',
|
||||
category: MODELS_AND_NODES,
|
||||
media: videoFor('Drops_3x3card_supported nodes.mp4', {
|
||||
en: 'Supported Nodes',
|
||||
'zh-CN': '支持的节点'
|
||||
}),
|
||||
title: { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
description: {
|
||||
en: 'Thousands of community and partner nodes, curated and verified to run on Comfy Cloud.',
|
||||
'zh-CN':
|
||||
'数千个社区与合作伙伴节点,经过精选与验证,可在 Comfy Cloud 上运行。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: {
|
||||
en: '/cloud/supported-nodes',
|
||||
'zh-CN': '/zh-CN/cloud/supported-nodes'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-enterprise',
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_3x3card_enterprise.png', {
|
||||
en: 'Comfy Enterprise',
|
||||
'zh-CN': 'Comfy 企业版'
|
||||
}),
|
||||
title: { en: 'Comfy Enterprise', 'zh-CN': 'Comfy 企业版' },
|
||||
description: {
|
||||
en: 'Enterprise-grade infrastructure for the creative engine inside your organization.',
|
||||
'zh-CN': '为您组织内创意引擎提供的企业级基础设施。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/cloud/enterprise', 'zh-CN': '/zh-CN/cloud/enterprise' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'learning-hub',
|
||||
category: COMMUNITY,
|
||||
media: imageFor('Drops_3x3_Learninghub.jpg', {
|
||||
en: 'Learning Hub',
|
||||
'zh-CN': '学习中心'
|
||||
}),
|
||||
title: { en: 'Learning Hub', 'zh-CN': '学习中心' },
|
||||
description: {
|
||||
en: 'Walkthroughs and ready-to-run workflows to take you from first render to production pipeline.',
|
||||
'zh-CN': '配套教程与开箱即用的工作流,带您从第一次渲染走向生产管线。'
|
||||
},
|
||||
cta: {
|
||||
label: { en: 'START LEARNING', 'zh-CN': '开始学习' },
|
||||
href: { en: '/learning', 'zh-CN': '/zh-CN/learning' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'share-comfy',
|
||||
badge: NEW_BADGE,
|
||||
category: COMMUNITY,
|
||||
media: videoFor('Drops_3x3card_Affilliate.mp4', {
|
||||
en: 'Comfy Affiliate',
|
||||
'zh-CN': 'Comfy Affiliate'
|
||||
}),
|
||||
title: {
|
||||
en: 'Comfy Affiliate',
|
||||
'zh-CN': 'Comfy Affiliate'
|
||||
},
|
||||
description: {
|
||||
en: 'Share Comfy with your audience and earn for every creator you bring on board.',
|
||||
'zh-CN': '与您的受众分享 Comfy,为您带来的每一位创作者获得回报。'
|
||||
},
|
||||
// /affiliates is locale-invariant: same URL in both locales.
|
||||
cta: {
|
||||
label: { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
href: { en: '/affiliates', 'zh-CN': '/affiliates' }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -180,6 +180,11 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.launches', locale),
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
@@ -1849,6 +1849,7 @@ const translations = {
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
@@ -4928,6 +4929,70 @@ const translations = {
|
||||
'affiliate.cta.termsLabel': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': '阅读联盟计划条款'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — head metadata
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.page.title': {
|
||||
en: 'ComfyUI Live Demo & Q&A — June 29 Launch Livestream',
|
||||
'zh-CN': 'ComfyUI 直播演示与问答 — 6 月 29 日发布直播'
|
||||
},
|
||||
'launches.page.description': {
|
||||
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See what’s new across desktop, cloud, and community, and get your questions answered.',
|
||||
'zh-CN':
|
||||
'6 月 29 日加入 ComfyUI 直播,观看实操产品演示并参与实时问答。了解桌面、云端和社区的最新内容,并获得解答。'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — hero section
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.hero.title': {
|
||||
en: 'Everything new in ComfyUI',
|
||||
'zh-CN': 'ComfyUI 全新内容'
|
||||
},
|
||||
'launches.hero.primary': {
|
||||
en: 'Download Desktop',
|
||||
'zh-CN': '下载桌面版'
|
||||
},
|
||||
'launches.hero.secondary': {
|
||||
en: 'Launch Cloud',
|
||||
'zh-CN': '启动云端'
|
||||
},
|
||||
'launches.hero.visualAlt': {
|
||||
en: 'Comfy',
|
||||
'zh-CN': 'Comfy'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.cta.heading': {
|
||||
en: 'Everything Comfy ships. All in one place.',
|
||||
'zh-CN': 'Comfy 的全部内容,一处尽享。'
|
||||
},
|
||||
'launches.cta.primary': {
|
||||
en: 'Open Comfy Cloud',
|
||||
'zh-CN': '打开 Comfy Cloud'
|
||||
},
|
||||
'launches.cta.secondary': {
|
||||
en: 'Try Workflow',
|
||||
'zh-CN': '试用工作流'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — launches grid
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.section.title': {
|
||||
en: 'Latest Launches',
|
||||
'zh-CN': '最新发布'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
20
apps/website/src/pages/launches.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
</BaseLayout>
|
||||
20
apps/website/src/pages/zh-CN/launches.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
</BaseLayout>
|
||||
25
apps/website/src/templates/drops/CtaSection.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CtaCenter01
|
||||
:heading="t('launches.cta.heading', locale)"
|
||||
:primary-cta="{
|
||||
label: t('launches.cta.primary', locale),
|
||||
href: externalLinks.cloud,
|
||||
target: '_blank'
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('launches.cta.secondary', locale),
|
||||
href: externalLinks.workflows,
|
||||
target: '_blank'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
76
apps/website/src/templates/drops/DropCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { Drop } from '../../data/drops'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import Badge from '../../components/ui/badge/Badge.vue'
|
||||
|
||||
import ButtonPill from '../../components/ui/button-pill/ButtonPill.vue'
|
||||
import Card from '../../components/ui/card/Card.vue'
|
||||
import CardContent from '../../components/ui/card/CardContent.vue'
|
||||
import CardDescription from '../../components/ui/card/CardDescription.vue'
|
||||
import CardFooter from '../../components/ui/card/CardFooter.vue'
|
||||
import CardHeader from '../../components/ui/card/CardHeader.vue'
|
||||
import CardTitle from '../../components/ui/card/CardTitle.vue'
|
||||
|
||||
const { drop, locale } = defineProps<{
|
||||
drop: Drop
|
||||
locale: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="group/pill-trigger relative h-full overflow-hidden">
|
||||
<a
|
||||
:href="drop.cta.href[locale]"
|
||||
:aria-label="`${drop.title[locale]} — ${drop.cta.label[locale]}`"
|
||||
class="rounded-4.5xl focus-visible:ring-primary-comfy-yellow absolute inset-0 z-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col-reverse">
|
||||
<CardHeader class="gap-2 px-6">
|
||||
<Badge variant="category">
|
||||
{{ drop.category[locale] }}
|
||||
</Badge>
|
||||
<CardTitle class="pt-4">
|
||||
{{ drop.title[locale] }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ drop.description[locale] }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="relative p-2">
|
||||
<div class="aspect-video w-full overflow-hidden rounded-4xl">
|
||||
<img
|
||||
v-if="drop.media.type === 'image'"
|
||||
:src="drop.media.src"
|
||||
:alt="drop.media.alt[locale]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="drop.media.src"
|
||||
:poster="drop.media.poster"
|
||||
:aria-label="drop.media.alt[locale]"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
|
||||
{{ drop.badge[locale] }}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<CardFooter class="mt-auto px-6 pb-6">
|
||||
<ButtonPill as="span" variant="ghost" icon-position="left">
|
||||
{{ drop.cta.label[locale] }}
|
||||
</ButtonPill>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
29
apps/website/src/templates/drops/DropsSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import DropCard from './DropCard.vue'
|
||||
import { drops } from '../../data/drops'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-warm-white text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('launches.section.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-10 grid grid-cols-1 gap-6 md:grid-cols-6 lg:mt-12">
|
||||
<div
|
||||
v-for="(drop, index) in drops"
|
||||
:key="drop.id"
|
||||
:class="index < 4 ? 'md:col-span-3' : 'md:col-span-2'"
|
||||
>
|
||||
<DropCard :drop :locale />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
35
apps/website/src/templates/drops/HeroSection.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import HeroLivestream01 from '../../components/blocks/HeroLivestream01.vue'
|
||||
import LaunchesHeroLogo from './LaunchesHeroLogo.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroLivestream01
|
||||
:title="t('launches.hero.title', locale)"
|
||||
:primary-cta="{
|
||||
label: t('launches.hero.primary', locale),
|
||||
href: routes.download
|
||||
}"
|
||||
:secondary-cta="{
|
||||
label: t('launches.hero.secondary', locale),
|
||||
href: externalLinks.cloud,
|
||||
target: '_blank'
|
||||
}"
|
||||
:youtube-video-id="livestream.youtubeVideoId"
|
||||
:start-date-time="livestream.startDateTime"
|
||||
:end-date-time="livestream.endDateTime"
|
||||
>
|
||||
<template #visual>
|
||||
<LaunchesHeroLogo :label="t('launches.hero.visualAlt', locale)" />
|
||||
</template>
|
||||
</HeroLivestream01>
|
||||
</template>
|
||||
55
apps/website/src/templates/drops/LaunchesHeroLogo.vue
Normal file
61
apps/website/src/templates/drops/SubscribeBanner.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
5
apps/website/src/templates/drops/livestream.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const livestream = {
|
||||
youtubeVideoId: 'yo7b_zHd20g',
|
||||
startDateTime: '2026-06-29T15:00:00Z',
|
||||
endDateTime: '2026-07-02T17:15:00Z'
|
||||
} as const
|
||||
18
apps/website/src/utils/cta.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveRel } from './cta'
|
||||
|
||||
describe('resolveRel', () => {
|
||||
it('prefers an explicit rel over the target-derived default', () => {
|
||||
expect(resolveRel({ rel: 'nofollow', target: '_blank' })).toBe('nofollow')
|
||||
})
|
||||
|
||||
it('adds noopener noreferrer for _blank targets', () => {
|
||||
expect(resolveRel({ target: '_blank' })).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('returns undefined for non-blank targets', () => {
|
||||
expect(resolveRel({ target: '_self' })).toBeUndefined()
|
||||
expect(resolveRel({})).toBeUndefined()
|
||||
})
|
||||
})
|
||||
10
apps/website/src/utils/cta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
export function resolveRel(cta: {
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
}): AnchorHTMLAttributes['rel'] {
|
||||
return (
|
||||
cta.rel ?? (cta.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @knipIgnoreUsedByStackedPR */
|
||||
export type VideoFormat = 'webm' | 'mp4'
|
||||
|
||||
export type VideoSource = {
|
||||
type VideoSource = {
|
||||
src: string
|
||||
type: `video/${VideoFormat}`
|
||||
format: VideoFormat
|
||||
|
||||
@@ -14,6 +14,24 @@
|
||||
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
|
||||
],
|
||||
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
|
||||
},
|
||||
{
|
||||
"source": "/payment/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Robots-Tag",
|
||||
"value": "noindex"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/:locale/payment/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Robots-Tag",
|
||||
"value": "noindex"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "test-missing-model-nested-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [10, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"title": "Resolved Shared Outer Subgraph",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["resolved_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "outer-subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"title": "Outer Subgraph with Promoted Missing Model",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "outer-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Outer Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [600, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "outer-ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "inner-subgraph-with-promoted-missing-model",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 2,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inner-subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Inner Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "inner-ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
/**
|
||||
@@ -43,7 +44,7 @@ export class VueNodeHelpers {
|
||||
.locator('.lg-slot--input')
|
||||
.filter({
|
||||
has: this.page.locator(
|
||||
`[data-slot-key="${getSlotKey(nodeId, slotIndex, true)}"]`
|
||||
`[data-slot-key="${getSlotKey(toNodeId(nodeId), slotIndex, true)}"]`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -251,14 +252,18 @@ export class VueNodeHelpers {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) return false
|
||||
|
||||
return await this.page.evaluate((key) => {
|
||||
const [nodeId, type, slotId] = key.split('-')
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
const [rawNodeId, type, slotId] = key.split('-')
|
||||
const nodeId = toNodeId(rawNodeId)
|
||||
return await this.page.evaluate(
|
||||
([nodeId, type, slotId]) => {
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)]?.links?.length
|
||||
},
|
||||
[nodeId, type, slotId] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly sidebarContent: Locator
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly essentialsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
@@ -101,8 +101,8 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
super(page, 'node-library')
|
||||
this.searchInput = page.getByPlaceholder('Search...')
|
||||
this.sidebarContent = page.locator('.sidebar-content-container')
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.allTab = this.getTab('All nodes')
|
||||
this.essentialsTab = this.getTab('Essentials')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import type {
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -42,11 +41,12 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selectedNodeIds = await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
return Object.keys(selected)
|
||||
})
|
||||
return selectedNodeIds.map(toNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,8 +114,8 @@ export class NodeOperationsHelper {
|
||||
return this.getNodeRefById(id)
|
||||
}
|
||||
|
||||
async getNodeRefById(id: NodeId): Promise<NodeReference> {
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
async getNodeRefById(id: SerializedNodeId): Promise<NodeReference> {
|
||||
return new NodeReference(toNodeId(id), this.comfyPage)
|
||||
}
|
||||
|
||||
async getNodeRefsByType(
|
||||
@@ -136,7 +136,7 @@ export class NodeOperationsHelper {
|
||||
},
|
||||
{ type, includeSubgraph }
|
||||
)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class NodeOperationsHelper {
|
||||
.app!.graph.nodes.filter((n: LGraphNode) => n.title === title)
|
||||
.map((n: LGraphNode) => n.id)
|
||||
}, title)
|
||||
).map((id: NodeId) => this.getNodeRefById(id))
|
||||
).map((id: SerializedNodeId) => this.getNodeRefById(id))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
@@ -45,9 +48,9 @@ async function orbitDragFromCanvasCenter(
|
||||
|
||||
export class Preview3DPipelineContext {
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly loadNodeId = '1'
|
||||
static readonly loadNodeId = toNodeId(1)
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly previewNodeId = '2'
|
||||
static readonly previewNodeId = toNodeId(2)
|
||||
|
||||
readonly load3d: Load3DHelper
|
||||
readonly preview3d: Load3DHelper
|
||||
@@ -61,9 +64,9 @@ export class Preview3DPipelineContext {
|
||||
)
|
||||
}
|
||||
|
||||
async getModelFileWidgetValue(nodeId: string): Promise<string> {
|
||||
async getModelFileWidgetValue(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.widgets) return ''
|
||||
const w = node.widgets.find((x) => x.name === 'model_file')
|
||||
const v = w?.value
|
||||
@@ -71,9 +74,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getLastTimeModelFile(nodeId: string): Promise<string> {
|
||||
async getLastTimeModelFile(nodeId: NodeId): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return ''
|
||||
const v = (node.properties as Record<string, unknown>)[
|
||||
'Last Time Model File'
|
||||
@@ -82,9 +85,9 @@ export class Preview3DPipelineContext {
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
|
||||
async getCameraStateFromProperties(nodeId: NodeId): Promise<unknown> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node?.properties) return null
|
||||
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
|
||||
if (cfg === null || typeof cfg !== 'object') return null
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
@@ -549,6 +550,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
static getTextSlotPosition(page: Page, nodeId: string) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
@@ -565,7 +567,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
static async expectWidgetBelowHeader(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
136
browser_tests/fixtures/utils/objectInfo.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import {
|
||||
getComboSpecComboOptions,
|
||||
isComboInputSpec,
|
||||
isComboInputSpecV1
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2,
|
||||
ComfyNodeDef,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
|
||||
type ObjectInfoResponse = Record<string, ComfyNodeDef>
|
||||
|
||||
type ComboInput = ComboInputSpec | ComboInputSpecV2
|
||||
|
||||
const OBJECT_INFO_ROUTE = '**/object_info'
|
||||
|
||||
function getRequiredInput(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string
|
||||
): InputSpec {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!input) {
|
||||
throw new Error(`Missing input ${nodeType}.${inputName}`)
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
function getComboInput(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string
|
||||
): ComboInput {
|
||||
const input = getRequiredInput(objectInfo, nodeType, inputName)
|
||||
if (isComboInputSpec(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
export function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: ReadonlyArray<string | number>
|
||||
): void {
|
||||
const input = getComboInput(objectInfo, nodeType, inputName)
|
||||
const nextValues = [...values]
|
||||
|
||||
if (isComboInputSpecV1(input)) {
|
||||
input[0] = nextValues
|
||||
return
|
||||
}
|
||||
|
||||
input[1] = { ...input[1], options: nextValues }
|
||||
}
|
||||
|
||||
export function appendComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: ReadonlyArray<string | number>
|
||||
): void {
|
||||
const input = getComboInput(objectInfo, nodeType, inputName)
|
||||
setComboInputOptions(objectInfo, nodeType, inputName, [
|
||||
...getComboSpecComboOptions(input),
|
||||
...values
|
||||
])
|
||||
}
|
||||
|
||||
export async function routeObjectInfoFromSetupApi(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void | Promise<void>
|
||||
): Promise<() => Promise<void>> {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
let objectInfo: ObjectInfoResponse
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await customize?.(objectInfo)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
|
||||
return async () => {
|
||||
if (page.isClosed()) return
|
||||
await page.unroute(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
|
||||
}
|
||||
}
|
||||
425
browser_tests/fixtures/utils/promotedMissingModel.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
|
||||
|
||||
interface PromotedMissingModelWorkflow {
|
||||
workflowName: string
|
||||
hostNodeId: number
|
||||
hostNodeTitle: string
|
||||
sharedDefinitionSiblingHostNodeId?: number
|
||||
sharedDefinitionSiblingHostNodeTitle?: string
|
||||
}
|
||||
|
||||
type RootWorkflowNode = {
|
||||
id: number | string
|
||||
widgets_values?: unknown[] | Record<string, unknown>
|
||||
}
|
||||
|
||||
type RootWorkflowData = ComfyWorkflowJSON & {
|
||||
nodes?: RootWorkflowNode[]
|
||||
}
|
||||
|
||||
export const NESTED_PROMOTED_MISSING_MODEL_WORKFLOW: PromotedMissingModelWorkflow =
|
||||
{
|
||||
workflowName: 'missing/missing_model_nested_promoted_widget',
|
||||
hostNodeId: 4,
|
||||
hostNodeTitle: 'Outer Subgraph with Promoted Missing Model',
|
||||
sharedDefinitionSiblingHostNodeId: 3,
|
||||
sharedDefinitionSiblingHostNodeTitle: 'Resolved Shared Outer Subgraph'
|
||||
}
|
||||
|
||||
export function getMissingModelLabel(group: Locator, modelName: string) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
export async function expectSingleMissingModelReference(
|
||||
group: Locator,
|
||||
modelName: string
|
||||
) {
|
||||
await expectMissingModelReferenceCount(group, modelName, 1)
|
||||
}
|
||||
|
||||
export async function expectMissingModelReferenceCount(
|
||||
group: Locator,
|
||||
modelName: string,
|
||||
count: number
|
||||
) {
|
||||
await expect(getMissingModelLabel(group, modelName)).toHaveCount(1)
|
||||
const badge = group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
if (count === 1) {
|
||||
await expect(badge).toBeHidden()
|
||||
return
|
||||
}
|
||||
await expect(badge).toBeVisible()
|
||||
await expect(badge).toHaveText(String(count))
|
||||
}
|
||||
|
||||
export async function loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
): Promise<Locator> {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, workflow.workflowName)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectSingleMissingModelReference(missingModelGroup, modelName)
|
||||
return missingModelGroup
|
||||
}
|
||||
|
||||
export async function loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
hostValues: Record<number, string>,
|
||||
modelName: string,
|
||||
expectedReferenceCount: number
|
||||
): Promise<Locator> {
|
||||
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, hostValues)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expectMissingModelReferenceCount(
|
||||
missingModelGroup,
|
||||
modelName,
|
||||
expectedReferenceCount
|
||||
)
|
||||
return missingModelGroup
|
||||
}
|
||||
|
||||
export async function expectNoMissingModelUi(comfyPage: ComfyPage) {
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
export async function selectVueComboPromotedModelByTitle(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string,
|
||||
modelName: string
|
||||
) {
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
nodeTitle,
|
||||
PROMOTED_MODEL_WIDGET_NAME,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectVueAssetPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
currentModelName: string,
|
||||
modelName: string
|
||||
) {
|
||||
await selectModelFromFormDropdown(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getNodeByTitle(workflow.hostNodeTitle),
|
||||
currentModelName,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectSectionComboPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
) {
|
||||
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
|
||||
const combo = panel.contentArea.getByRole('combobox', {
|
||||
name: PROMOTED_MODEL_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await combo.click()
|
||||
await comfyPage.page
|
||||
.getByRole('option', { name: modelName, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
export async function selectSectionAssetPromotedModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
currentModelName: string,
|
||||
modelName: string
|
||||
) {
|
||||
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
|
||||
await selectModelFromFormDropdown(
|
||||
comfyPage,
|
||||
panel.contentArea,
|
||||
currentModelName,
|
||||
modelName
|
||||
)
|
||||
}
|
||||
|
||||
export async function setLegacyPromotedComboModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
modelName: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ hostNodeId, widgetName, value }) => {
|
||||
type LegacyPromotedWidget = {
|
||||
name?: string
|
||||
value?: unknown
|
||||
callback?: (value: string) => void
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
type LegacyPromotedNode = {
|
||||
onWidgetChanged?: (
|
||||
name: string,
|
||||
newValue: string,
|
||||
oldValue: unknown,
|
||||
widget: LegacyPromotedWidget
|
||||
) => void
|
||||
widgets?: LegacyPromotedWidget[]
|
||||
}
|
||||
type LegacyPromotedGraph = {
|
||||
getNodeById: (nodeId: number) => LegacyPromotedNode | undefined
|
||||
}
|
||||
|
||||
const currentGraph = window.app?.graph as LegacyPromotedGraph | undefined
|
||||
const hostNode: LegacyPromotedNode | undefined =
|
||||
currentGraph?.getNodeById(hostNodeId)
|
||||
if (!hostNode) {
|
||||
throw new Error(`Expected subgraph host node ${hostNodeId}`)
|
||||
}
|
||||
|
||||
const widget = hostNode.widgets?.find(
|
||||
(entry) => entry.name === widgetName
|
||||
) as LegacyPromotedWidget | undefined
|
||||
if (!widget) {
|
||||
throw new Error(`Expected host ${widgetName} widget`)
|
||||
}
|
||||
|
||||
const oldValue = widget.value
|
||||
if (widget.setValue) {
|
||||
widget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
hostNode.onWidgetChanged?.(
|
||||
widget.name ?? widgetName,
|
||||
value,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
},
|
||||
{
|
||||
hostNodeId: workflow.hostNodeId,
|
||||
widgetName: PROMOTED_MODEL_WIDGET_NAME,
|
||||
value: modelName
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function selectLegacyPromotedAssetModel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
assetId: string
|
||||
) {
|
||||
await clickLegacyHostPromotedWidget(comfyPage, workflow)
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
const assetCard = modal.locator(`[data-asset-id="${assetId}"]`)
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use', exact: true }).click()
|
||||
await expect(modal).toBeHidden()
|
||||
}
|
||||
|
||||
export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
expectedStaleInteriorWidgets: Array<{
|
||||
subgraphNodeIdToEnter: string
|
||||
nodeTitle: string
|
||||
}>,
|
||||
resolvedModelName: string,
|
||||
staleModelName: string
|
||||
) {
|
||||
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, {
|
||||
[workflow.hostNodeId]: resolvedModelName
|
||||
})
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle(workflow.hostNodeTitle)
|
||||
.getByRole('combobox', { name: PROMOTED_MODEL_WIDGET_NAME, exact: true })
|
||||
await expect(promotedModelCombo).toContainText(resolvedModelName)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
|
||||
for (const step of expectedStaleInteriorWidgets) {
|
||||
await enterSubgraphForStaleInteriorCheck(
|
||||
comfyPage,
|
||||
step.subgraphNodeIdToEnter
|
||||
)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const staleCombo = node.getByRole('combobox', {
|
||||
name: PROMOTED_MODEL_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await expect(
|
||||
staleCombo,
|
||||
`${step.nodeTitle} should expose the stale linked interior widget`
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
staleCombo,
|
||||
`${step.nodeTitle} should keep the stale interior value`
|
||||
).toContainText(staleModelName)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
}
|
||||
|
||||
async function openHostNodeParametersPanel(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow
|
||||
): Promise<PropertiesPanelHelper> {
|
||||
await comfyPage.vueNodes.selectNode(String(workflow.hostNodeId))
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(panel.getTab('Parameters')).toBeVisible()
|
||||
await panel.switchToTab('Parameters')
|
||||
return panel
|
||||
}
|
||||
|
||||
async function loadPromotedMissingModelWithHostValues(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow,
|
||||
hostValues: Record<number, string>
|
||||
) {
|
||||
const graphData = readPromotedMissingModelWorkflow(workflow.workflowName)
|
||||
for (const [hostNodeId, value] of Object.entries(hostValues)) {
|
||||
setRootHostWidgetValue(graphData, Number(hostNodeId), value)
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadGraphData(graphData)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
|
||||
function readPromotedMissingModelWorkflow(workflowName: string) {
|
||||
return JSON.parse(
|
||||
readFileSync(assetPath(`${workflowName}.json`), 'utf-8')
|
||||
) as RootWorkflowData
|
||||
}
|
||||
|
||||
function setRootHostWidgetValue(
|
||||
graphData: RootWorkflowData,
|
||||
hostNodeId: number,
|
||||
value: string
|
||||
) {
|
||||
const hostNode = graphData.nodes?.find(
|
||||
(node) => Number(node.id) === hostNodeId
|
||||
)
|
||||
if (!hostNode) throw new Error(`Expected host node ${hostNodeId}`)
|
||||
|
||||
if (Array.isArray(hostNode.widgets_values)) {
|
||||
hostNode.widgets_values[0] = value
|
||||
return
|
||||
}
|
||||
|
||||
hostNode.widgets_values = {
|
||||
...(hostNode.widgets_values ?? {}),
|
||||
[PROMOTED_MODEL_WIDGET_NAME]: value
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModelFromFormDropdown(
|
||||
comfyPage: ComfyPage,
|
||||
root: Locator,
|
||||
currentModelName: string,
|
||||
nextModelName: string
|
||||
) {
|
||||
const trigger = root
|
||||
.getByRole('button', { name: currentModelName, exact: true })
|
||||
.first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByText(nextModelName, { exact: true }).click()
|
||||
await expect(menu).toBeHidden()
|
||||
}
|
||||
|
||||
async function clickLegacyHostPromotedWidget(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: PromotedMissingModelWorkflow
|
||||
) {
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(workflow.hostNodeId)
|
||||
await hostNode.centerOnNode()
|
||||
const widget = await hostNode.getWidgetByName(PROMOTED_MODEL_WIDGET_NAME)
|
||||
await widget.click()
|
||||
}
|
||||
|
||||
async function enterSubgraphForStaleInteriorCheck(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
) {
|
||||
const numericNodeId = Number(nodeId)
|
||||
if (Number.isNaN(numericNodeId)) {
|
||||
throw new Error(`Expected numeric subgraph node id, got ${nodeId}`)
|
||||
}
|
||||
|
||||
const normalizedNodeId = String(numericNodeId)
|
||||
const enterButton =
|
||||
comfyPage.vueNodes.getSubgraphEnterButton(normalizedNodeId)
|
||||
if ((await enterButton.count()) > 0) {
|
||||
await comfyPage.vueNodes.enterSubgraph(normalizedNodeId)
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.evaluate((targetNodeId) => {
|
||||
const graph = window.app?.canvas.graph
|
||||
const node = graph?.getNodeById(targetNodeId)
|
||||
if (!node?.isSubgraphNode()) {
|
||||
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
|
||||
}
|
||||
window.app!.canvas.setGraph(node.subgraph)
|
||||
}, toNodeId(normalizedNodeId))
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
@@ -44,6 +45,7 @@ export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
@@ -91,7 +93,7 @@ export async function getPromotedWidgets(
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
localNodeId
|
||||
)
|
||||
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const HOST_NODE_ID = toNodeId(2)
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -39,16 +41,22 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
function evaluateGraph() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const nodeIds = {
|
||||
switchCfg: toNodeId(120),
|
||||
ksampler85: toNodeId(85),
|
||||
ksampler86: toNodeId(86)
|
||||
}
|
||||
|
||||
return comfyPage.page.evaluate((nodeIds) => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
const switchCfg = subgraph.getNodeById(nodeIds.switchCfg)
|
||||
const ksampler85 = subgraph.getNodeById(nodeIds.ksampler85)
|
||||
const ksampler86 = subgraph.getNodeById(nodeIds.ksampler86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
@@ -74,7 +82,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
if (
|
||||
String(link.origin_id) === '120' &&
|
||||
String(link.target_id) === '85'
|
||||
)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
@@ -89,7 +100,7 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
// Poll graph state once, then assert all properties
|
||||
|
||||
@@ -4,6 +4,9 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const IMAGE_COMPARE_NODE_ID = toNodeId(1)
|
||||
|
||||
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -29,15 +32,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, value }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
},
|
||||
{ value }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, value }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
@@ -450,11 +453,11 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
|
||||
const minWidth = 400
|
||||
const minHeight = 350
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const graphNode = window.app!.graph.getNodeById(1)
|
||||
const size = await comfyPage.page.evaluate((nodeId) => {
|
||||
const graphNode = window.app!.graph.getNodeById(nodeId)
|
||||
if (!graphNode?.size) return null
|
||||
return { width: graphNode.size[0], height: graphNode.size[1] }
|
||||
})
|
||||
}, IMAGE_COMPARE_NODE_ID)
|
||||
expect(
|
||||
size,
|
||||
'ImageCompare node id 1 must exist in loaded workflow graph'
|
||||
@@ -600,15 +603,15 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, url }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
{ nodeId: IMAGE_COMPARE_NODE_ID, url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -95,15 +96,15 @@ test.describe('Image Crop', () => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
({ nodeId, bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
{ nodeId: toNodeId(1), bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@ import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const getGizmoConfig = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const modelConfig = n?.properties?.['Model Config'] as
|
||||
| { gizmo?: { enabled: boolean; mode: string } }
|
||||
| undefined
|
||||
return modelConfig?.gizmo
|
||||
})
|
||||
}, toNodeId(1))
|
||||
|
||||
test.describe('Load3D Gizmo Controls', () => {
|
||||
test(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test.describe('Load3D', () => {
|
||||
test(
|
||||
@@ -67,13 +68,13 @@ test.describe('Load3D', () => {
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const n = window.app!.graph.getNodeById(nodeId)
|
||||
const config = n?.properties?.['Scene Config'] as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
return config?.backgroundColor
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe('#cc3333')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -331,7 +332,8 @@ wstest(
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.images?.[0]?.filename,
|
||||
toNodeId(1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
|
||||
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
|
||||
@@ -32,12 +34,13 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: SerializedNodeId) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
|
||||
@@ -33,49 +33,11 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Node library opens via sidebar', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const sidebarContent = comfyPage.page.locator(
|
||||
'.comfy-vue-side-bar-container'
|
||||
)
|
||||
await expect(sidebarContent).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await expect(essentialsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking essentials tab shows essential node cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essential node cards have node names', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.essentialsTab.click()
|
||||
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
@@ -86,21 +48,18 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
await tab.allTab.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
|
||||
|
||||
await essentialsTab.click()
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
await tab.essentialsTab.click()
|
||||
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
|
||||
await allNodesTab.click()
|
||||
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -245,8 +246,10 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
.click()
|
||||
|
||||
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
|
||||
(nodeId) =>
|
||||
window.app!.graph!.getNodeById(nodeId)?.outputs[0]?.links
|
||||
?.length ?? 0,
|
||||
toNodeId(2)
|
||||
)
|
||||
expect(
|
||||
replacedNodeOutputLinkCount,
|
||||
|
||||
@@ -10,8 +10,20 @@ import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
expectNoMissingModelUi,
|
||||
loadPromotedMissingModelAndOpenErrorsTab,
|
||||
selectLegacyPromotedAssetModel,
|
||||
selectSectionAssetPromotedModel,
|
||||
selectVueAssetPromotedModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -20,6 +32,8 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
@@ -55,7 +69,25 @@ const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
|
||||
id: 'test-resolved-promoted-model',
|
||||
name: RESOLVED_PROMOTED_MODEL_NAME,
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: RESOLVED_PROMOTED_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
const promotedModelTest = createCloudAssetsFixture([
|
||||
RESOLVED_PROMOTED_MODEL_ASSET
|
||||
])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
@@ -353,13 +385,94 @@ test.describe(
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
}, toNodeId(1))
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest.describe(
|
||||
'Errors tab - Cloud promoted subgraph missing models',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
promotedModelTest.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
promotedModelTest.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectVueAssetPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectSectionAssetPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectLegacyPromotedAssetModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_ASSET.id
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
} from '@e2e/fixtures/utils/objectInfo'
|
||||
import {
|
||||
createRouteMockJob,
|
||||
jobsRouteFixture
|
||||
@@ -86,50 +90,6 @@ interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
type ObjectInfoResponse = Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
>
|
||||
|
||||
function setComboInputOptions(
|
||||
objectInfo: ObjectInfoResponse,
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
) {
|
||||
const nodeInfo = objectInfo[nodeType]
|
||||
if (!nodeInfo) {
|
||||
throw new Error(`Missing object_info entry for ${nodeType}`)
|
||||
}
|
||||
|
||||
const requiredInputs = nodeInfo.input?.required
|
||||
if (!requiredInputs) {
|
||||
throw new Error(`Missing required inputs for ${nodeType}`)
|
||||
}
|
||||
|
||||
const input = requiredInputs[inputName]
|
||||
if (!Array.isArray(input)) {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
|
||||
}
|
||||
|
||||
const [valuesOrType, options] = input
|
||||
const optionsObject =
|
||||
options && typeof options === 'object' && !Array.isArray(options)
|
||||
if (Array.isArray(valuesOrType)) {
|
||||
input[0] = values
|
||||
} else if (valuesOrType !== 'COMBO') {
|
||||
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
|
||||
}
|
||||
|
||||
if (optionsObject) {
|
||||
Object.assign(options, { options: values })
|
||||
} else if (!Array.isArray(valuesOrType)) {
|
||||
throw new Error(
|
||||
`Expected ${nodeType}.${inputName} to have options metadata`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -161,57 +121,10 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
async function routeSetupObjectInfo(
|
||||
page: Page,
|
||||
customize?: (objectInfo: ObjectInfoResponse) => void
|
||||
) {
|
||||
const setupApiUrl =
|
||||
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
|
||||
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
|
||||
|
||||
const objectInfoRouteHandler = async (route: Route) => {
|
||||
try {
|
||||
const response = await fetch(objectInfoUrl, {
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? 'text/plain',
|
||||
body: await response.text()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const objectInfo = (await response.json()) as ObjectInfoResponse
|
||||
customize?.(objectInfo)
|
||||
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(objectInfo)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await page.route('**/object_info', objectInfoRouteHandler)
|
||||
return async () =>
|
||||
await page.unroute('**/object_info', objectInfoRouteHandler)
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -225,13 +138,16 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) => {
|
||||
for (const node of emptyMediaLoaderNodes) {
|
||||
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
|
||||
node.serverOnlyOption
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await use(page)
|
||||
@@ -246,7 +162,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeSetupObjectInfo(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
|
||||
const state: CloudUploadAssetState = {
|
||||
isUploadedAssetAvailable: false
|
||||
|
||||
@@ -8,12 +8,46 @@ import {
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
appendComboInputOptions,
|
||||
routeObjectInfoFromSetupApi
|
||||
} from '@e2e/fixtures/utils/objectInfo'
|
||||
import {
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
expectMissingModelReferenceCount,
|
||||
expectNoMissingModelUi,
|
||||
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
|
||||
expectSingleMissingModelReference,
|
||||
getMissingModelLabel,
|
||||
loadPromotedMissingModelAndOpenErrorsTab,
|
||||
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
|
||||
selectSectionComboPromotedModel,
|
||||
selectVueComboPromotedModelByTitle,
|
||||
setLegacyPromotedComboModel
|
||||
} from '@e2e/fixtures/utils/promotedMissingModel'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
const promotedModelTest = test.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
@@ -169,7 +203,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -265,7 +301,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
@@ -381,92 +419,184 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
let missingModelGroup: Locator
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
await test.step('A: shared-definition active host reports the missing model', async () => {
|
||||
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
})
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
|
||||
const siblingHostNodeId =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
||||
if (siblingHostNodeId === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
const siblingHost =
|
||||
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
|
||||
await siblingHost.centerOnNode()
|
||||
await siblingHost.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
|
||||
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
|
||||
await openErrorsTab(comfyPage)
|
||||
await expectSingleMissingModelReference(
|
||||
missingModelGroup,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
await test.step('C: changing the active host promoted widget resolves the model', async () => {
|
||||
const activeHost = await comfyPage.nodeOps.getNodeRefById(
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
|
||||
)
|
||||
await activeHost.centerOnNode()
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('D: the missing model UI clears', async () => {
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
})
|
||||
|
||||
await test.step('E: two missing shared-definition hosts report two references', async () => {
|
||||
const siblingHostNodeId =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
|
||||
if (siblingHostNodeId === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host')
|
||||
}
|
||||
|
||||
missingModelGroup =
|
||||
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
{
|
||||
[siblingHostNodeId]: FAKE_MODEL_NAME,
|
||||
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
|
||||
FAKE_MODEL_NAME
|
||||
},
|
||||
FAKE_MODEL_NAME,
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('F: changing one missing host leaves the other missing reference', async () => {
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
await expectMissingModelReferenceCount(
|
||||
missingModelGroup,
|
||||
FAKE_MODEL_NAME,
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('G: changing the remaining missing host clears the model error', async () => {
|
||||
const siblingHostTitle =
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
|
||||
if (siblingHostTitle === undefined) {
|
||||
throw new Error('Expected a shared-definition sibling host title')
|
||||
}
|
||||
|
||||
await selectVueComboPromotedModelByTitle(
|
||||
comfyPage,
|
||||
siblingHostTitle,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
promotedModelTest(
|
||||
'Changing an OSS Vue promoted model from the Parameters tab clears a nested subgraph error',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await selectSectionComboPromotedModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Changing an OSS legacy promoted model clears a nested subgraph error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadPromotedMissingModelAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
|
||||
await setLegacyPromotedComboModel(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
RESOLVED_PROMOTED_MODEL_NAME
|
||||
)
|
||||
|
||||
await expectNoMissingModelUi(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Refreshing a resolved promoted missing model clears the combo invalid state',
|
||||
{ tag: ['@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
|
||||
.getByRole('combobox', { name: 'ckpt_name', exact: true })
|
||||
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
|
||||
|
||||
const objectInfoRoute = /\/object_info$/
|
||||
try {
|
||||
await comfyPage.page.route(objectInfoRoute, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
|
||||
comfyPage.page,
|
||||
(objectInfo) =>
|
||||
appendComboInputOptions(
|
||||
objectInfo,
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
.click()
|
||||
@@ -478,11 +608,31 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'true'
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.page.unroute(objectInfoRoute)
|
||||
await unrouteObjectInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
promotedModelTest(
|
||||
'Reloading a resolved nested promoted model ignores stale interior values',
|
||||
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
|
||||
comfyPage,
|
||||
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
|
||||
[
|
||||
{
|
||||
subgraphNodeIdToEnter: '4',
|
||||
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
|
||||
},
|
||||
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
|
||||
],
|
||||
RESOLVED_PROMOTED_MODEL_NAME,
|
||||
FAKE_MODEL_NAME
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -4,15 +4,16 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
type NodeSnapshot = { id: number } & Position
|
||||
type NodeSnapshot = { id: NodeId } & Position
|
||||
|
||||
async function getAllNodePositions(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeSnapshot[]> {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((n) => ({
|
||||
id: n.id as number,
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
@@ -21,7 +22,7 @@ async function getAllNodePositions(
|
||||
|
||||
async function getNodePosition(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
nodeId: NodeId
|
||||
): Promise<Position | undefined> {
|
||||
return comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
|
||||
|
||||
@@ -1095,6 +1095,33 @@ test.describe('Assets sidebar - drag and drop', () => {
|
||||
const fileComboWidget = await nodes[0].getWidget(0)
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
|
||||
})
|
||||
|
||||
test('Loading as workflow reuses asset name', async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job',
|
||||
preview_output: {
|
||||
filename: `testimage.png`,
|
||||
type: 'temp',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
const path = comfyPage.assetPath('workflowInMedia/workflow.webp')
|
||||
await comfyPage.page.route('**/view?**', (route) => route.fulfill({ path }))
|
||||
|
||||
const { assetsTab } = comfyPage.menu
|
||||
await assetsTab.open()
|
||||
await assetsTab.waitForAssets()
|
||||
await expect(assetsTab.assetCards).toHaveCount(1)
|
||||
|
||||
const targetPosition = { x: 400, y: 100 }
|
||||
await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition })
|
||||
|
||||
const getTabName = () => comfyPage.menu.topbar.getActiveTabName()
|
||||
await expect.poll(getTabName).toContain('testimage')
|
||||
})
|
||||
})
|
||||
|
||||
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
|
||||
@@ -10,20 +10,6 @@ test.describe('Node library sidebar V2', () => {
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test('Can switch between tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await tab.blueprintsTab.click()
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('All tab displays node tree with folders', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
@@ -123,8 +109,9 @@ test.describe('Node library sidebar V2', () => {
|
||||
|
||||
test('Blueprint previews include description', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.blueprintsTab.click()
|
||||
await tab.allTab.click()
|
||||
|
||||
await tab.expandFolder('Comfy Blueprints')
|
||||
await tab.getNode('test blueprint').hover()
|
||||
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
|
||||
await expect(tab.nodePreview).toContainText('Inverts the image')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, mergeTests } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
|
||||
|
||||
@@ -198,7 +199,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootNodeTitle = await comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.graph!.getNodeById(nodeId)?.title ?? null,
|
||||
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
|
||||
toNodeId(OUTER_SUBGRAPH_NODE_ID_IN_NESTED)
|
||||
)
|
||||
expect(rootNodeTitle).toBe(newName)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
interface SubgraphNodePosition {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
@@ -57,12 +58,12 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('5')
|
||||
const subgraphNode = graph.getNodeById(nodeId)
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
}, toNodeId(5))
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
|
||||
@@ -260,17 +261,18 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const localSubgraphNodeId = toNodeId(subgraphNodeId)
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(nodeId) => window.app!.canvas.graph!.getNodeById(nodeId)!.progress,
|
||||
subgraphNodeId
|
||||
localSubgraphNodeId
|
||||
)
|
||||
)
|
||||
.toBe(0.5)
|
||||
@@ -287,7 +289,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
@@ -298,11 +300,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
const localSubgraphNodeId = toNodeId(subgraphNodeId)
|
||||
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.7
|
||||
}, subgraphNodeId)
|
||||
}, localSubgraphNodeId)
|
||||
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
|
||||
@@ -471,11 +471,10 @@ test.describe(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
let initialWidgetCount = 0
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
@@ -39,9 +42,10 @@ async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
const localHostNodeId = toNodeId(hostNodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
const hostNode = graph.getNodeById(id)
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
@@ -80,7 +84,7 @@ async function getPrimitiveFanoutSnapshot(
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}, localHostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
@@ -103,19 +107,20 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
) {
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const hostNodeId = toNodeId(hostSubgraphNodeId)
|
||||
const interiorNodeIds = widgets.map(([id]) => toNodeId(id))
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
const interiorNode = hostNode.subgraph.getNodeById(id)
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
[hostNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
expect(results).toEqual(widgets.map(() => true))
|
||||
@@ -570,8 +575,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.map((n) => String(n.id))
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
@@ -587,10 +591,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
return graph._nodes.map((n) => Number(n.id)).sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
@@ -633,18 +634,18 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
)
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
const SENTINEL_IDS = new Set(['-1', '-10', '-20'])
|
||||
const isSentinelNodeId = (id: number | string) =>
|
||||
SENTINEL_IDS.has(String(id))
|
||||
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
id: NodeId,
|
||||
g: typeof graph
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
if (!g.getNodeById(id)) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
@@ -460,16 +461,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
|
||||
const subgraphNodeId = toNodeId(19)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((entry: { name: string }) =>
|
||||
entry.name.includes('seed')
|
||||
)
|
||||
return widget?.label || widget?.name || null
|
||||
})
|
||||
}, subgraphNodeId)
|
||||
)
|
||||
.toBe(RENAMED_LABEL)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
@@ -67,7 +67,7 @@ function slotLocator(
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) {
|
||||
const key = getSlotKey(String(nodeId), slotIndex, isInput)
|
||||
const key = getSlotKey(nodeId, slotIndex, isInput)
|
||||
return page.locator(`[data-slot-key="${key}"]`)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
dismissErrorOverlay,
|
||||
@@ -59,12 +60,13 @@ async function selectLoadImageNodeForPaste(
|
||||
comfyPage: ComfyPage,
|
||||
loadImageId: string
|
||||
): Promise<void> {
|
||||
const localLoadImageId = toNodeId(loadImageId)
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(Number(nodeId))
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
||||
window.app!.canvas.selectNode(node)
|
||||
window.app!.canvas.current_node = node
|
||||
}, loadImageId)
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
@@ -147,7 +149,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
|
||||
@@ -2,14 +2,15 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test('Can display a slot mismatched from widget type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const emptyLatent = window.app!.graph.getNodeById(5)!
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const emptyLatent = window.app!.graph.getNodeById(nodeId)!
|
||||
emptyLatent.inputs[0].type = 'INT,FLOAT'
|
||||
})
|
||||
}, toNodeId(5))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const width = comfyPage.vueNodes
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
@@ -15,6 +16,7 @@ async function getCropValue(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
): Promise<CropValue> {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
|
||||
@@ -34,7 +36,7 @@ async function getCropValue(
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}, localNodeId)
|
||||
}
|
||||
|
||||
async function setCropBounds(
|
||||
@@ -42,6 +44,7 @@ async function setCropBounds(
|
||||
nodeId: number,
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) {
|
||||
const localNodeId = toNodeId(nodeId)
|
||||
await comfyPage.page.evaluate(
|
||||
({ id, b }) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
@@ -51,7 +54,7 @@ async function setCropBounds(
|
||||
w.callback?.(b)
|
||||
}
|
||||
},
|
||||
{ id: nodeId, b: bounds }
|
||||
{ id: localNodeId, b: bounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
@@ -17,7 +18,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
|
||||
toNodeId(10)
|
||||
)
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Page, Request } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
@@ -140,13 +138,13 @@ test.describe('Workflow settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Workflow.SortNodeIdOnSave', () => {
|
||||
async function getSerializedNodeIds(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeId[]> {
|
||||
): Promise<SerializedNodeId[]> {
|
||||
return (await comfyPage.workflow.getExportedWorkflow()).nodes.map(
|
||||
(n) => n.id
|
||||
)
|
||||
}
|
||||
|
||||
function ascendingById(ids: NodeId[]): NodeId[] {
|
||||
function ascendingById(ids: SerializedNodeId[]): SerializedNodeId[] {
|
||||
return [...ids].sort((a, b) => Number(a) - Number(b))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,21 @@ Date: 2026-03-23
|
||||
|
||||
Proposed
|
||||
|
||||
### Amendment (2026-06-19, PR 12617)
|
||||
|
||||
The single central registry this ADR calls the "World" was superseded during
|
||||
implementation. Runtime entity data is held in dedicated Pinia stores keyed by
|
||||
string IDs — `widgetValueStore`, `domWidgetStore`, `layoutStore`,
|
||||
`nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`.
|
||||
Widget values are keyed by `WidgetId` (`graphId:nodeId:name`, see
|
||||
`src/types/widgetId.ts`); the `world/*` layer (`widgetValueIO`, `entityIds`,
|
||||
`brand`, `WidgetEntityId`) was deleted. The ECS principles below still hold —
|
||||
plain-data components, separation of data from behavior, command-driven
|
||||
mutation, and no god-object growth — realized across those stores. Where the
|
||||
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)`).
|
||||
|
||||
## 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.
|
||||
@@ -40,7 +55,7 @@ Six entity kinds, each with a branded ID type:
|
||||
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
|
||||
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
|
||||
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
|
||||
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
|
||||
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetId` |
|
||||
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
|
||||
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
|
||||
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
|
||||
@@ -54,7 +69,6 @@ Each entity kind gets a nominal/branded type wrapping its underlying primitive.
|
||||
```ts
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
@@ -63,7 +77,12 @@ type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' }
|
||||
```
|
||||
|
||||
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
|
||||
> **Amended (PR 12617):** Widgets are keyed by a branded composite **string**,
|
||||
> `WidgetId = graphId:nodeId:name` (`src/types/widgetId.ts`), rather than a
|
||||
> synthetic numeric `WidgetEntityId`. The composite stays self-documenting and
|
||||
> survives renames at the store layer. The numeric per-kind brands above for
|
||||
> Node/Link/Reroute/Group remain aspirational and unshipped; treat them as
|
||||
> design intent. Slots have no independent ID yet.
|
||||
|
||||
### Component Decomposition
|
||||
|
||||
@@ -139,18 +158,24 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
|
||||
| `GroupVisual` | `color` |
|
||||
| `GroupChildren` | child entity refs (nodes, reroutes) |
|
||||
|
||||
### World
|
||||
### Dedicated stores
|
||||
|
||||
A central registry (the "World") maps entity IDs to their component sets. One
|
||||
World exists per workflow instance, containing all entities across all nesting
|
||||
levels. Each entity carries a `graphScope` identifier linking it to its
|
||||
containing graph. The World also maintains a scope registry mapping each
|
||||
`graphId` to its parent (or null for the root graph).
|
||||
Component data lives in a set of dedicated Pinia stores, each owning one
|
||||
concern and keyed by a string ID that embeds its graph scope (for example
|
||||
`widgetValueStore` keyed by `WidgetId = graphId:nodeId:name`, `layoutStore`
|
||||
keyed by `nodeId`/`linkId`/`rerouteId`, `nodeOutputStore` keyed by
|
||||
`subgraphId:nodeId`). Each store provides a clear-by-graph lifecycle hook
|
||||
(`clearGraph(graphId)`) and query helpers. A scope registry maps each `graphId`
|
||||
to its parent (or null for the root graph).
|
||||
|
||||
> The original design centralized this in one "World" registry per workflow
|
||||
> instance; PR 12617 replaced that with the dedicated stores above. The
|
||||
> remainder of this section describes scoping, which applies per store.
|
||||
|
||||
The "single source of truth" claim in this ADR is scoped to one workflow
|
||||
instance. In a future linked-subgraph model, shared definitions can be loaded
|
||||
into multiple workflow instances, but mutable runtime components
|
||||
(`WidgetValue`, execution state, selection, transient layout caches) remain
|
||||
instance, per concern. In a future linked-subgraph model, shared definitions
|
||||
can be loaded into multiple workflow instances, but mutable runtime state
|
||||
(widget values, execution state, selection, transient layout caches) remains
|
||||
instance-scoped unless explicitly declared shareable.
|
||||
|
||||
### Subgraph recursion model
|
||||
@@ -166,7 +191,7 @@ queries by `graphScope`.
|
||||
|
||||
### Systems (future work)
|
||||
|
||||
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
|
||||
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Initial candidates:
|
||||
|
||||
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
|
||||
- **SerializationSystem** — queries all components to produce/consume workflow JSON
|
||||
@@ -178,25 +203,23 @@ System design is deferred to a future ADR. For detailed before/after walkthrough
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Define types** — branded IDs, component interfaces, World type in a new `src/ecs/` directory
|
||||
2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible)
|
||||
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
|
||||
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
|
||||
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
|
||||
1. **Define types** — string-key ID types (for example `WidgetId`) and plain-data component interfaces, owned by the store for each concern
|
||||
2. **Bridge layer** — adapter functions that read component data from existing class instances (zero-copy where possible)
|
||||
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on store-backed components rather than class properties
|
||||
4. **Incremental extraction** — migrate one component at a time from classes into its dedicated store, using the bridge layer for backward compatibility
|
||||
5. **Deprecate class properties** — once all consumers read from the store, mark class properties as deprecated
|
||||
|
||||
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
|
||||
|
||||
### Relationship to ADR 0003 (Command Pattern / CRDT)
|
||||
|
||||
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
|
||||
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the dedicated stores that hold it. They are complementary architectural layers:
|
||||
|
||||
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
|
||||
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
|
||||
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
|
||||
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the relevant stores.
|
||||
- **The dedicated stores** (ADR 0008) hold component data and expose mutation APIs (for example `useLayoutMutations()`, `widgetValueStore.setValue`); each owns its own transaction boundary.
|
||||
|
||||
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
|
||||
|
||||
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
|
||||
A store's imperative mutators are internal implementation. External callers submit commands; each mutating store wraps its writes in a transaction (the Y.js-backed `layoutStore` already does this). This follows Redux: internal mutation is imperative, while the public API is action-based.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
@@ -210,26 +233,26 @@ For the full design showing how each lifecycle scenario maps to a command, see [
|
||||
|
||||
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
|
||||
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
|
||||
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
|
||||
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
|
||||
- Branded IDs (including the composite `WidgetId` string) prevent a class of bugs where IDs are accidentally used across entity kinds
|
||||
- Each dedicated store provides a single source of truth for its concern inside a workflow instance, simplifying debugging and state inspection
|
||||
- Aligns with the CRDT layout system direction from ADR 0003
|
||||
|
||||
### Negative
|
||||
|
||||
- Additional indirection: reading a node's position requires a World lookup instead of `node.pos`
|
||||
- Additional indirection: reading a node's position requires a store lookup instead of `node.pos`
|
||||
- Learning curve for contributors unfamiliar with ECS patterns
|
||||
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
|
||||
- Widgets and Slots need synthetic IDs, adding ID management complexity
|
||||
|
||||
### Render-Loop Performance Implications and Mitigations
|
||||
|
||||
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
|
||||
Replacing direct property reads (`node.pos`) with store lookups (for example `layoutStore` position reads) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
|
||||
|
||||
Planned mitigations for the ECS render path:
|
||||
|
||||
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
|
||||
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
|
||||
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
|
||||
3. Allow a hot-path storage upgrade behind a store's API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
|
||||
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
|
||||
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
|
||||
|
||||
@@ -247,7 +270,6 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [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.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [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 |
|
||||
@@ -258,5 +280,5 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
|
||||
- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
||||
- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`.
|
||||
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
|
||||
- Widgets are addressed by the composite `WidgetId` string, so they need no synthetic counter. The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters cover the kinds that have numeric IDs.
|
||||
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the store-backed component data and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
_In which we examine the shadow material of a codebase in individuation, verify its self-reported symptoms, and note where the ego's aspirations outpace the psyche's readiness for transformation._
|
||||
|
||||
> **Post-pivot status (PR 12617).** This analysis was written against the
|
||||
> single-World ECS target. The project has since chosen dedicated Pinia stores
|
||||
> over one unified World, which acts on several of the concerns raised below.
|
||||
> Resolution notes are inlined where the pivot answers a critique; the still-open
|
||||
> gaps (extension-callback continuity, atomicity/undo, Y.js ↔ ECS coexistence)
|
||||
> remain live. Verification snapshots predate PR 12617.
|
||||
|
||||
---
|
||||
|
||||
## I. On the Accuracy of Self-Diagnosis
|
||||
@@ -15,7 +22,7 @@ The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines —
|
||||
|
||||
Some thirty specific line references were verified against the living code. The `renderingColor` getter sits precisely at line 328. The `drawNode()` method begins exactly at line 5554, and within it, at lines 5562 and 5564, the render pass mutates state — `_setConcreteSlots()` and `arrange()` — just as the documents confess. The scattered `_version++` increments appear at every claimed location across all three files. The module-scope store invocations in `LLink.ts:24` and `Reroute.ts:23` are exactly where indicated.
|
||||
|
||||
The stores — all six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects. The `PromotionStore` does maintain its ref-counted maps. The `LayoutStore` does wrap Y.js CRDTs.
|
||||
The stores — six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects, keyed by `WidgetId`. The `LayoutStore` does wrap Y.js CRDTs. (The `PromotionStore` named in the original snapshot was removed by PR 12617; promoted value state now lives in `WidgetValueStore`, and `PreviewExposureStore` holds host-scoped preview exposures.)
|
||||
|
||||
This level of factual accuracy — 28 out of 30 sampled citation checks
|
||||
(93.3%) — is, one might say, the work of a consciousness that has genuinely
|
||||
@@ -47,6 +54,12 @@ This is the individuation dream: the fragmented psyche imagines itself unified,
|
||||
|
||||
It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality.
|
||||
|
||||
> **Resolved (PR 12617).** The single World was set aside. The project keeps the
|
||||
> fragments deliberately apart — dedicated stores, each holding one concern and
|
||||
> keyed by its own string identity. The integration the dream sought lives in the
|
||||
> shared discipline (plain-data components, command-driven mutation), with the
|
||||
> stores standing on their own.
|
||||
|
||||
### The Line-Count Comparisons
|
||||
|
||||
The lifecycle scenarios compare current implementations against projected ECS equivalents:
|
||||
@@ -93,6 +106,12 @@ But one must be careful not to mistake diversity for disorder. Some of these com
|
||||
|
||||
The documents present branded IDs as an unqualified improvement. They are an improvement in _type safety_. Whether they are an improvement in _comprehensibility_ depends on whether the system provides good lookup APIs. The analysis would benefit from acknowledging this tradeoff rather than presenting it as a pure gain.
|
||||
|
||||
> **Resolved (PR 12617).** The pivot honored this concern. `WidgetValueStore`
|
||||
> keeps the self-documenting composite as its key, branded as a string
|
||||
> (`WidgetId = graphId:nodeId:name`, `src/types/widgetId.ts`), gaining cross-kind
|
||||
> safety while preserving the structural meaning the synthetic integer would have
|
||||
> shed.
|
||||
|
||||
## V. On the Subgraph: The Child Who Contains the Parent
|
||||
|
||||
The documents describe the `Subgraph extends LGraph` relationship as a circular dependency. This is technically accurate and architecturally concerning. But it is also, symbolically, the most interesting structure in the entire system.
|
||||
@@ -115,13 +134,13 @@ This is sound. The documents would benefit from being equally realistic about th
|
||||
|
||||
### Factual Corrections Required
|
||||
|
||||
| Document | Error | Correction |
|
||||
| --------------------- | ---------------------------------- | ---------------------------------- |
|
||||
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
|
||||
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
|
||||
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
|
||||
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
|
||||
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | `reconcile()` / `getOrCreate()` |
|
||||
| Document | Error | Correction |
|
||||
| --------------------- | ---------------------------------- | ----------------------------------------------------- |
|
||||
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
|
||||
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
|
||||
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
|
||||
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
|
||||
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | moot — `PromotedWidgetViewManager` removed (PR 12617) |
|
||||
|
||||
### Analytical Gaps
|
||||
|
||||
@@ -129,7 +148,7 @@ This is sound. The documents would benefit from being equally realistic about th
|
||||
2. **Atomicity guarantees** are claimed but not mechanically specified.
|
||||
3. **Y.js / ECS coexistence** is an open architectural question the documents do not engage.
|
||||
4. **ECS line-count projections** are aspirational and should be marked as estimates.
|
||||
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything."
|
||||
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything." _(Resolved by PR 12617: `WidgetId` keeps the composite as a branded string.)_
|
||||
|
||||
### What the Documents Do Well
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Appendix: ECS Pattern Survey
|
||||
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation
|
||||
analysis that shipped slice 1 of
|
||||
[ADR 0008](../adr/0008-entity-component-system.md). This appendix records
|
||||
which structural patterns our `src/world/` substrate adopts, which it
|
||||
deliberately departs from, and where the trade-offs are load-bearing rather
|
||||
than incidental. Thyseus is called out specifically because it is the most
|
||||
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
|
||||
closest external analog to the command layer ADR 0003 / ADR 0008 are
|
||||
converging on, so it gets dedicated treatment in §2.5 and §3.5._
|
||||
> **Superseded (PR 12617).** The single `src/world/` substrate this appendix
|
||||
> analyzes was removed; the project adopted dedicated Pinia stores
|
||||
> (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`,
|
||||
> `subgraphNavigationStore`, `previewExposureStore`) keyed by string IDs. §1
|
||||
> (the external library survey) remains valid reference material and supports
|
||||
> the dedicated-store direction — its first unanimous finding, that components
|
||||
> live with the code that owns them, is exactly what per-domain stores do. §2–§4
|
||||
> describe the deleted `src/world/` substrate (`world.ts`, `entityIds.ts`,
|
||||
> `widgetComponents.ts`, `WidgetEntityId`) and are retained for historical
|
||||
> rationale only; read their references to "the World" as "the relevant
|
||||
> dedicated store."
|
||||
|
||||
The in-code anchors for the load-bearing constraints discussed below are the
|
||||
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
|
||||
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
|
||||
contract) — see §3 below.
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, Thyseus, and Bevy. This appendix records which structural patterns
|
||||
the surveyed libraries share, which the project departs from, and where the
|
||||
trade-offs carry weight. Thyseus is called out specifically because it is the
|
||||
most Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is
|
||||
the closest external analog to the command layer ADR 0003 / ADR 0008 converge
|
||||
on, so it gets dedicated treatment in §2.5 and §3.5._
|
||||
|
||||
---
|
||||
|
||||
@@ -49,9 +53,9 @@ Two structural patterns are unanimous across the surveyed libraries:
|
||||
because it commits to a full system-execution runtime, not just
|
||||
storage.
|
||||
|
||||
Our slice-1 end state — five source files under
|
||||
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
|
||||
this band.
|
||||
The dedicated-store end state — each store a small, focused module keyed by a
|
||||
string ID — sits squarely in this band: a small surface per store, with
|
||||
component shapes defined next to the store that owns them.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,12 +145,11 @@ export function spawnEntities(commands: Commands) {
|
||||
```
|
||||
|
||||
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
|
||||
deferred mutations against a command buffer; the World applies them at
|
||||
deferred mutations against a command buffer; the substrate applies them at
|
||||
defined sync points in the schedule. This is the same shape Bevy uses
|
||||
and is the closest direct external analog to the mutation layer
|
||||
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
|
||||
[World API and Command Layer](./ecs-world-command-api.md) describe for
|
||||
this codebase.
|
||||
and is the closest direct external analog to the per-store mutation layer
|
||||
[ADR 0003](../adr/0003-crdt-based-layout-system.md) describes for this
|
||||
codebase (realized as store mutation APIs such as `useLayoutMutations()`).
|
||||
|
||||
We deliberately match the **shape** of this pattern: external callers
|
||||
submit commands; only the executor calls the World's imperative
|
||||
@@ -172,8 +175,8 @@ yet:
|
||||
|
||||
The point of calling Thyseus out separately is that when ADR 0008 lands
|
||||
its command executor slice, "what does this look like in Thyseus?" is a
|
||||
load-bearing comparison point — not a curiosity. Diverging from the
|
||||
Bevy/Thyseus shape there should require an explicit justification, not
|
||||
comparison point worth taking seriously. Diverging from the
|
||||
Bevy/Thyseus shape there should require an explicit justification rather than
|
||||
silent drift.
|
||||
|
||||
---
|
||||
@@ -181,7 +184,7 @@ silent drift.
|
||||
## 3. Patterns We Explicitly Do NOT Adopt
|
||||
|
||||
Each of the following is a real industry idiom we considered and rejected
|
||||
on load-bearing grounds. None of these are pure performance trade-offs.
|
||||
on structural grounds. None of these are pure performance trade-offs.
|
||||
|
||||
### 3.1 Replace-on-write usage idioms
|
||||
|
||||
@@ -215,8 +218,8 @@ SoA storage spreads each component's fields across parallel typed arrays,
|
||||
so the per-entity "row object" is reconstructed on read. **A future
|
||||
migration to SoA would lose the proxy on the row object** — and with it
|
||||
the shared-reactive-identity contract that `BaseWidget._state` and the
|
||||
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
|
||||
just a perf optimization decision.
|
||||
`widgetValueStore` facade rely on. This constraint carries real weight
|
||||
beyond a perf optimization decision.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/world.ts](../../src/world/world.ts) — copied here for
|
||||
@@ -261,7 +264,7 @@ The contract is pinned in the doc-comment at the top of
|
||||
* Entity IDs are deterministic, content-addressed, and string-prefix
|
||||
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` carries real weight:
|
||||
* consumers consistently pass `rootGraph.id` so widgets viewed at
|
||||
* different subgraph depths share identity. Migrating to numeric IDs
|
||||
* would break cross-subgraph value sharing. See ADR 0008 and
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
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).
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (what it looks like with the World), and a **Key Differences** table.
|
||||
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.
|
||||
|
||||
## 1. Node Removal
|
||||
|
||||
@@ -63,47 +65,43 @@ Problems: the graph method manually disconnects every slot, cleans up reroutes,
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant VS as VersionSystem
|
||||
participant LM as useLayoutMutations()
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant NOS as nodeOutputStore
|
||||
participant DWS as domWidgetStore
|
||||
|
||||
Caller->>CS: removeNode(world, nodeId)
|
||||
Caller->>CS: removeNode(nodeId)
|
||||
|
||||
CS->>W: getComponent(nodeId, Connectivity)
|
||||
W-->>CS: { inputSlotIds, outputSlotIds }
|
||||
CS->>LS: read node links (incoming + outgoing)
|
||||
LS-->>CS: linkIds
|
||||
|
||||
loop each slotId
|
||||
CS->>W: getComponent(slotId, SlotConnection)
|
||||
W-->>CS: { linkIds }
|
||||
loop each linkId
|
||||
CS->>CS: removeLink(world, linkId)
|
||||
Note over CS,W: removes Link entity + updates remote slots
|
||||
end
|
||||
CS->>W: deleteEntity(slotId)
|
||||
loop each linkId
|
||||
CS->>LM: deleteLink(linkId)
|
||||
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
|
||||
end
|
||||
|
||||
CS->>W: getComponent(nodeId, WidgetContainer)
|
||||
W-->>CS: { widgetIds }
|
||||
loop each widgetId
|
||||
CS->>W: deleteEntity(widgetId)
|
||||
loop each widget on node
|
||||
CS->>WVS: deleteWidget(widgetId)
|
||||
CS->>DWS: unregisterWidget(widgetId)
|
||||
end
|
||||
|
||||
CS->>W: deleteEntity(nodeId)
|
||||
Note over W: removes Position, NodeVisual, NodeType,<br/>Connectivity, Execution, Properties,<br/>WidgetContainer — all at once
|
||||
|
||||
CS->>VS: markChanged()
|
||||
CS->>NOS: removeNodeOutputs(nodeId)
|
||||
CS->>LM: deleteNode(nodeId)
|
||||
Note over CS,LS: coordinated cleanup across stores —<br/>each store drops its entry for the node
|
||||
```
|
||||
|
||||
### 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 knows Connectivity + SlotConnection |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteEntity()` removes all components atomically |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | RenderSystem sees missing entity on next frame |
|
||||
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | World deletion IS the cleanup |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | System snapshots affected components before deletion |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only World + 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
|
||||
|
||||
@@ -165,41 +163,37 @@ Problems: serialization logic lives in 6 different `serialize()` methods across
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant SS as SerializationSystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant CLS as node class state
|
||||
|
||||
Caller->>SS: serialize(world)
|
||||
Caller->>SS: serialize(graphId)
|
||||
|
||||
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
|
||||
W-->>SS: all node entities with their components
|
||||
SS->>LS: read node layouts (position, size, z-index)
|
||||
LS-->>SS: layout entries for graphId
|
||||
|
||||
SS->>W: queryAll(LinkEndpoints)
|
||||
W-->>SS: all link entities
|
||||
SS->>LS: read links + reroutes for graphId
|
||||
LS-->>SS: link / reroute entries
|
||||
|
||||
SS->>W: queryAll(SlotIdentity, SlotConnection)
|
||||
W-->>SS: all slot entities
|
||||
SS->>WVS: getWidget(widgetId) per node widget
|
||||
WVS-->>SS: WidgetState values
|
||||
|
||||
SS->>W: queryAll(RerouteLinks, Position)
|
||||
W-->>SS: all reroute entities
|
||||
SS->>CLS: read type / properties / flags
|
||||
CLS-->>SS: per-node class data
|
||||
|
||||
SS->>W: queryAll(GroupMeta, GroupChildren, Position)
|
||||
W-->>SS: all group entities
|
||||
|
||||
SS->>W: queryAll(SubgraphStructure, SubgraphMeta)
|
||||
W-->>SS: all subgraph entities
|
||||
|
||||
SS->>SS: assemble JSON from component data
|
||||
SS->>SS: assemble JSON from store entries + class state
|
||||
SS-->>Caller: SerializedGraph
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------------- | ----------------------------------------------- | ---------------------------------------------- |
|
||||
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem |
|
||||
| Widget values | Collected inline during `node.serialize()` | WidgetValue component queried directly |
|
||||
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat query — SubgraphStructure has entity refs |
|
||||
| Adding a new component | Modify the entity's `serialize()` method | Add component to query in SerializationSystem |
|
||||
| Testing | Need full object graph to test serialization | Mock World with test components |
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------------- | ----------------------------------------------- | --------------------------------------------------------- |
|
||||
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem reading the stores |
|
||||
| Widget values | Collected inline during `node.serialize()` | `widgetValueStore.getWidget(widgetId)` read directly |
|
||||
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat read — layout entries carry scope tags, no recursion |
|
||||
| Adding a new component | Modify the entity's `serialize()` method | Read one more store in SerializationSystem |
|
||||
| Testing | Need full object graph to test serialization | Seed the stores with test entries |
|
||||
|
||||
## 3. Deserialization
|
||||
|
||||
@@ -274,64 +268,48 @@ Problems: two-phase creation is necessary because nodes need to reference each o
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant SS as SerializationSystem
|
||||
participant W as World
|
||||
participant LS as LayoutSystem
|
||||
participant LM as useLayoutMutations()
|
||||
participant WVS as widgetValueStore
|
||||
participant ES as ExecutionSystem
|
||||
|
||||
Caller->>SS: deserialize(world, data)
|
||||
Caller->>SS: deserialize(graphId, data)
|
||||
|
||||
SS->>W: clear() [remove all entities]
|
||||
SS->>WVS: clearGraph(graphId)
|
||||
Note over SS,WVS: drop stale widget entries for this graph
|
||||
|
||||
Note over SS,W: All entities created in one pass — no two-phase needed
|
||||
Note over SS,LM: All entries created in one pass — no two-phase needed
|
||||
|
||||
loop each node in data
|
||||
SS->>W: createEntity(NodeEntityId)
|
||||
SS->>W: setComponent(id, Position, {...})
|
||||
SS->>W: setComponent(id, NodeType, {...})
|
||||
SS->>W: setComponent(id, NodeVisual, {...})
|
||||
SS->>W: setComponent(id, Properties, {...})
|
||||
SS->>W: setComponent(id, Execution, {...})
|
||||
SS->>LM: createNode(nodeId, { position, size, ... })
|
||||
end
|
||||
|
||||
loop each slot in data
|
||||
SS->>W: createEntity(SlotEntityId)
|
||||
SS->>W: setComponent(id, SlotIdentity, {...})
|
||||
SS->>W: setComponent(id, SlotConnection, {...})
|
||||
end
|
||||
|
||||
Note over SS,W: Slots reference links by ID — no resolution needed yet
|
||||
|
||||
loop each link in data
|
||||
SS->>W: createEntity(LinkEntityId)
|
||||
SS->>W: setComponent(id, LinkEndpoints, {...})
|
||||
SS->>LM: createLink(linkId, source, target)
|
||||
end
|
||||
|
||||
Note over SS,W: Connectivity assembled from slot/link components
|
||||
Note over SS,LM: links reference node + slot IDs directly,<br/>no instance resolution needed
|
||||
|
||||
loop each widget in data
|
||||
SS->>W: createEntity(WidgetEntityId)
|
||||
SS->>W: setComponent(id, WidgetIdentity, {...})
|
||||
SS->>W: setComponent(id, WidgetValue, {...})
|
||||
SS->>WVS: registerWidget(widgetId, { value, ... })
|
||||
end
|
||||
|
||||
SS->>SS: create reroutes, groups, subgraphs similarly
|
||||
SS->>SS: create reroutes, groups via layout mutations;<br/>subgraph scopes tagged on entries
|
||||
|
||||
Note over SS,W: Systems react to populated World
|
||||
Note over SS,ES: Systems read the populated stores
|
||||
|
||||
SS->>LS: runLayout(world)
|
||||
SS->>ES: computeExecutionOrder(world)
|
||||
SS->>ES: computeExecutionOrder(graphId)
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — components reference IDs, not instances |
|
||||
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: WidgetValue component written directly |
|
||||
| Store population | Side effect of `widget.setNodeId()` | World IS the store — writing component IS population |
|
||||
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems query World after deserialization |
|
||||
| Subgraph ordering | Topological sort required | Flat write — SubgraphStructure just holds entity IDs |
|
||||
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entity → skip; components that loaded are still valid |
|
||||
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — links reference string IDs, not instances |
|
||||
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: `widgetValueStore.registerWidget(widgetId, state)` |
|
||||
| Store population | Side effect of `widget.setNodeId()` | Direct: writing the store entry is the population |
|
||||
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems read the stores after deserialization |
|
||||
| Subgraph ordering | Topological sort required | Flat write — scope tags on entries, no instance ordering |
|
||||
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entry → skip; entries that loaded are still valid |
|
||||
|
||||
## 4. Pack Subgraph
|
||||
|
||||
@@ -394,50 +372,50 @@ Problems: 200+ lines in one method. Manual boundary link analysis. Clone-seriali
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant SNS as subgraphNavigationStore
|
||||
|
||||
Caller->>CS: packSubgraph(world, selectedEntityIds)
|
||||
Caller->>CS: packSubgraph(selectedNodeIds)
|
||||
|
||||
CS->>W: query Connectivity + SlotConnection for selected nodes
|
||||
CS->>LS: read links for selected nodes
|
||||
CS->>CS: classify links as internal vs boundary
|
||||
|
||||
CS->>W: create new GraphId scope in scopes registry
|
||||
CS->>SNS: register new subgraph graphId
|
||||
|
||||
Note over CS,W: Create SubgraphNode entity in parent scope
|
||||
Note over CS,LM: Create SubgraphNode layout entry in parent graph
|
||||
|
||||
CS->>W: createEntity(NodeEntityId) [the SubgraphNode]
|
||||
CS->>W: setComponent(nodeId, Position, { center of selection })
|
||||
CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface })
|
||||
CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' })
|
||||
CS->>LM: createNode(subgraphNodeId, { position: center of selection })
|
||||
CS->>CS: record SubgraphNode interface (boundary slots)
|
||||
|
||||
Note over CS,W: Re-parent selected entities into new graph scope
|
||||
Note over CS,LS: Re-tag selected entries into new graph scope
|
||||
|
||||
loop each selected entity
|
||||
CS->>W: update graphScope to new graphId
|
||||
loop each selected node + link
|
||||
CS->>LS: set graphId scope tag to new subgraph graphId
|
||||
end
|
||||
|
||||
Note over CS,W: Create boundary slots on SubgraphNode
|
||||
Note over CS,LM: Reconnect boundary links to SubgraphNode slots
|
||||
|
||||
loop each boundary input link
|
||||
CS->>W: create SlotEntity on SubgraphNode
|
||||
CS->>W: update LinkEndpoints to target new slot
|
||||
CS->>LM: deleteLink(oldLinkId)
|
||||
CS->>LM: createLink(newLinkId, source, subgraphNode input slot)
|
||||
end
|
||||
|
||||
loop each boundary output link
|
||||
CS->>W: create SlotEntity on SubgraphNode
|
||||
CS->>W: update LinkEndpoints to source from new slot
|
||||
CS->>LM: deleteLink(oldLinkId)
|
||||
CS->>LM: createLink(newLinkId, subgraphNode output slot, target)
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId |
|
||||
| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots |
|
||||
| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together |
|
||||
| Code size | 200+ lines | ~50 lines in system |
|
||||
| Undo | `beforeChange()`/`afterChange()` wraps everything | Snapshot affected components before mutation |
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Entity movement | Clone → serialize → configure → remove originals | Re-tag entries: change graphId scope tag on store entries |
|
||||
| Boundary links | Disconnect → remove → recreate → reconnect | `deleteLink`/`createLink` against the new SubgraphNode slots |
|
||||
| Intermediate inconsistency | Graph is partially disconnected during operation | Mutations batch together as one command sequence |
|
||||
| Code size | 200+ lines | ~50 lines in system |
|
||||
| Undo | `beforeChange()`/`afterChange()` wraps everything | Layout mutation commands replay and undo as a batch |
|
||||
|
||||
## 5. Unpack Subgraph
|
||||
|
||||
@@ -496,48 +474,49 @@ Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant SNS as subgraphNavigationStore
|
||||
|
||||
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
|
||||
Caller->>CS: unpackSubgraph(subgraphNodeId)
|
||||
|
||||
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
|
||||
W-->>CS: { graphId, interface }
|
||||
CS->>CS: read SubgraphNode interface (boundary slots)
|
||||
|
||||
CS->>W: query entities where graphScope = graphId
|
||||
W-->>CS: all child entities (nodes, links, reroutes, etc.)
|
||||
CS->>LS: query entries where graphId scope = subgraph graphId
|
||||
LS-->>CS: child entries (nodes, links, reroutes)
|
||||
|
||||
Note over CS,W: Re-parent entities to containing graph scope
|
||||
Note over CS,LS: Re-tag entries to containing graph scope
|
||||
|
||||
loop each child entity
|
||||
CS->>W: update graphScope to parent scope
|
||||
loop each child entry
|
||||
CS->>LS: set graphId scope tag to parent scope
|
||||
end
|
||||
|
||||
Note over CS,W: Reconnect boundary links
|
||||
Note over CS,LM: Reconnect boundary links
|
||||
|
||||
loop each boundary slot in interface
|
||||
CS->>W: getComponent(slotId, SlotConnection)
|
||||
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
|
||||
CS->>LM: deleteLink(boundaryLinkId)
|
||||
CS->>LM: createLink(newLinkId, external slot → internal node slot)
|
||||
end
|
||||
|
||||
CS->>W: deleteEntity(subgraphNodeId)
|
||||
CS->>W: remove graphId from scopes registry
|
||||
CS->>LM: deleteNode(subgraphNodeId)
|
||||
CS->>SNS: drop subgraph graphId
|
||||
|
||||
Note over CS,W: Offset positions
|
||||
Note over CS,LM: Offset positions
|
||||
|
||||
loop each moved entity
|
||||
CS->>W: update Position component
|
||||
loop each moved node
|
||||
CS->>LM: moveNode(nodeId, position + offset)
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ----------------- | --------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs, only graphScope changes |
|
||||
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities |
|
||||
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes |
|
||||
| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place |
|
||||
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
|
||||
| Aspect | Current | ECS |
|
||||
| ----------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entries keep their IDs, only the scope tag changes |
|
||||
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as SubgraphNode interface slots |
|
||||
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Re-tag store entries between scopes |
|
||||
| Link reconnection | Remap origin_id/target_id, create new LLink objects | `deleteLink`/`createLink` against the resolved endpoints |
|
||||
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
|
||||
|
||||
## 6. Connect Slots
|
||||
|
||||
@@ -591,33 +570,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant VS as VersionSystem
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
|
||||
Caller->>CS: connect(world, outputSlotId, inputSlotId)
|
||||
Caller->>CS: connect(outputSlot, inputSlot)
|
||||
|
||||
CS->>W: getComponent(inputSlotId, SlotConnection)
|
||||
CS->>LS: read input slot link
|
||||
opt already connected
|
||||
CS->>CS: removeLink(world, existingLinkId)
|
||||
CS->>LM: deleteLink(existingLinkId)
|
||||
end
|
||||
|
||||
CS->>W: createEntity(LinkEntityId)
|
||||
CS->>W: setComponent(linkId, LinkEndpoints, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
|
||||
|
||||
CS->>W: update SlotConnection on outputSlotId (add linkId)
|
||||
CS->>W: update SlotConnection on inputSlotId (set linkId)
|
||||
|
||||
CS->>VS: markChanged()
|
||||
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, reroutes) | ConnectivitySystem (queries World) |
|
||||
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `markChanged()` — one call |
|
||||
| Reroute handling | Manual: iterate chain, add linkId to each | RerouteLinks component updated by system |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Component update: `setComponent(slotId, SlotConnection, ...)` |
|
||||
| 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
|
||||
@@ -688,57 +662,61 @@ parent IDs all remapped independently. ~300 lines across multiple methods.
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CS as ClipboardSystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant CB as Clipboard
|
||||
|
||||
rect rgb(40, 40, 60)
|
||||
Note over User,CB: Copy
|
||||
User->>CS: copy(world, selectedEntityIds)
|
||||
CS->>W: snapshot all components for selected entities
|
||||
CS->>W: snapshot components for child entities (slots, widgets)
|
||||
CS->>W: snapshot connected links (LinkEndpoints)
|
||||
CS->>CB: store component snapshot
|
||||
User->>CS: copy(selectedNodeIds)
|
||||
CS->>LS: snapshot layout entries (nodes, links, reroutes)
|
||||
CS->>WVS: snapshot WidgetState for each widgetId
|
||||
CS->>CB: store cross-store snapshot
|
||||
end
|
||||
|
||||
rect rgb(40, 60, 40)
|
||||
Note over User,CB: Paste
|
||||
User->>CS: paste(world, position)
|
||||
User->>CS: paste(position)
|
||||
CS->>CB: retrieve snapshot
|
||||
|
||||
CS->>CS: generate ID remap table (old → new branded IDs)
|
||||
CS->>CS: build ID remap table (old → new nodeId / WidgetId)
|
||||
|
||||
loop each entity in snapshot
|
||||
CS->>W: createEntity(newId)
|
||||
loop each component
|
||||
CS->>W: setComponent(newId, type, remappedData)
|
||||
Note over CS,W: entity ID refs in component data<br/>are remapped via table
|
||||
end
|
||||
loop each node in snapshot
|
||||
CS->>LM: createNode(newNodeId, remapped layout)
|
||||
end
|
||||
loop each link in snapshot
|
||||
CS->>LM: createLink(newLinkId, remapped endpoints)
|
||||
Note over CS,LM: node + slot refs remapped via table
|
||||
end
|
||||
loop each widget in snapshot
|
||||
CS->>WVS: registerWidget(newWidgetId, WidgetState)
|
||||
end
|
||||
|
||||
CS->>CS: offset all Position components to cursor
|
||||
CS->>LM: batchMoveNodes(offset all to cursor)
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Copy format | Clone → serialize → JSON (format depends on class) | Component snapshot (uniform format for all entities) |
|
||||
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | Single remap table applied to all entity ID refs in all components |
|
||||
| Paste reconstruction | `createNode()` → `add()` → `configure()` → `connect()` per node | `createEntity()` → `setComponent()` per entity (flat) |
|
||||
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot includes SubgraphStructure component with entity refs |
|
||||
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Copy format | Clone → serialize → JSON (format depends on class) | Store-entry snapshot (uniform shape across stores) |
|
||||
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | One remap table applied to string keys (`nodeId`, `WidgetId`) |
|
||||
| Paste reconstruction | `createNode()` → `add()` → `configure()` → `connect()` per node | `createNode`/`createLink`/`registerWidget` per entry (flat) |
|
||||
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot carries scope tags; remap rewrites graphId keys |
|
||||
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
|
||||
|
||||
## Summary: Cross-Cutting Benefits
|
||||
|
||||
| Benefit | Scenarios Where It Applies |
|
||||
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| **Atomic operations** | Node Removal, Pack/Unpack — no intermediate inconsistent state |
|
||||
| **No scattered `_version++`** | All scenarios — VersionSystem handles change tracking |
|
||||
| **No callback cascades** | Deserialization, Connect — systems query World instead of firing callbacks |
|
||||
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table instead of per-type logic |
|
||||
| **Entity deletion = cleanup** | Node Removal — `deleteEntity()` removes all components |
|
||||
| **No two-phase creation** | Deserialization — components reference IDs, not instances |
|
||||
| **Move instead of clone** | Pack/Unpack — entities keep their IDs, just change scope |
|
||||
| **Testable in isolation** | All scenarios — mock World, test one system |
|
||||
| **Undo/redo for free** | All scenarios — snapshot components before mutation, restore on undo |
|
||||
| Benefit | Scenarios Where It Applies |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Batched operations** | Node Removal, Pack/Unpack — mutations apply together as one command sequence |
|
||||
| **No scattered `_version++`** | All scenarios — layout mutation commands carry change tracking |
|
||||
| **No callback cascades** | Deserialization, Connect — systems read the stores instead of firing callbacks |
|
||||
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table over string keys instead of per-type logic |
|
||||
| **Coordinated cleanup** | Node Removal — `deleteWidget` + `deleteLink`/`deleteNode` + `removeNodeOutputs` + `unregisterWidget` |
|
||||
| **No two-phase creation** | Deserialization — store entries reference string IDs, not instances |
|
||||
| **Move instead of clone** | Pack/Unpack — entries keep their IDs, only the scope tag changes |
|
||||
| **Testable in isolation** | All scenarios — seed the relevant stores, test one system |
|
||||
| **Undo/redo for free** | All scenarios — layout mutation commands replay and undo |
|
||||
|
||||
@@ -10,6 +10,14 @@ target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
|
||||
For verified accuracy of these documents, see
|
||||
[Appendix: Critical Analysis](appendix-critical-analysis.md).
|
||||
|
||||
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
|
||||
> string IDs, one store per concern (widget values, DOM widgets, layout, node
|
||||
> outputs, subgraph navigation, preview exposure). The earlier "single unified
|
||||
> 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
|
||||
> around dedicated stores; shipped work is marked ✅.
|
||||
|
||||
## Planning assumptions
|
||||
|
||||
- The bridge period is expected to span 2-3 release cycles.
|
||||
@@ -23,36 +31,16 @@ For verified accuracy of these documents, see
|
||||
Zero behavioral risk. Prepares the codebase for extraction without changing
|
||||
runtime semantics. All items are independently shippable.
|
||||
|
||||
### 0a. Centralize version counter
|
||||
### 0a. Centralize version counter ✅ Shipped
|
||||
|
||||
`graph._version++` appears in 19 locations across 7 files. The counter is only
|
||||
read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It
|
||||
is not used for dirty-checking, caching, or reactivity.
|
||||
`LGraph.incrementVersion()` exists and is used everywhere. The counter is only
|
||||
read for debug display in `LGraphCanvas.renderInfo()`; it is not used for
|
||||
dirty-checking, caching, or reactivity.
|
||||
|
||||
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
|
||||
increments.
|
||||
**Remaining cleanup:** One stray direct `_version++` at `LGraph.ts:831` should
|
||||
be replaced with `incrementVersion()`.
|
||||
|
||||
```
|
||||
incrementVersion(): void {
|
||||
this._version++
|
||||
}
|
||||
```
|
||||
|
||||
| File | Sites |
|
||||
| ---------------------- | ------------------------------------------------------- |
|
||||
| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) |
|
||||
| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) |
|
||||
| `LGraphCanvas.ts` | 2 (lines 3084, 7880) |
|
||||
| `BaseWidget.ts` | 1 (line 439) |
|
||||
| `SubgraphInput.ts` | 1 (line 137) |
|
||||
| `SubgraphInputNode.ts` | 1 (line 190) |
|
||||
| `SubgraphOutput.ts` | 1 (line 102) |
|
||||
|
||||
**Why first:** Creates the seam where a VersionSystem can later intercept,
|
||||
batch, or replace the mechanism. Mechanical find-and-replace with zero
|
||||
behavioral change.
|
||||
|
||||
**Risk:** None. Existing null guards at call sites are preserved.
|
||||
**Risk:** None. Mechanical one-line change; existing null guards preserved.
|
||||
|
||||
### 0b. Add missing ID type aliases
|
||||
|
||||
@@ -79,246 +67,198 @@ Five factual errors verified during code review (see
|
||||
- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should
|
||||
be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240
|
||||
lines not ~180
|
||||
- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on
|
||||
PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Types and World Shell
|
||||
## Phase 1: Types and Dedicated Stores
|
||||
|
||||
Introduces the ECS type vocabulary and an empty World. No migration of existing
|
||||
code — new types coexist with old ones.
|
||||
Introduces the ID type vocabulary and the dedicated stores. Phase 1 end-state is
|
||||
N dedicated Pinia stores, each keyed by a composite string ID, coexisting with
|
||||
legacy class instances.
|
||||
|
||||
### 1a. Branded entity ID types
|
||||
### 1a. Branded string ID types ✅ Shipped (PR 12617)
|
||||
|
||||
Define branded types in a new `src/ecs/entityId.ts`:
|
||||
|
||||
```
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity
|
||||
```
|
||||
|
||||
Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at
|
||||
system boundaries (deserialization, legacy bridge).
|
||||
|
||||
**Does NOT change existing code.** The branded types are new exports consumed
|
||||
only by new ECS code.
|
||||
|
||||
**Risk:** Low. New files, no modifications to existing code.
|
||||
|
||||
**Consideration:** `NodeId = number | string` is the current type. The branded
|
||||
`NodeEntityId` narrows to `number`. The `string` branch exists solely for
|
||||
subgraph-related nodes (GroupNode hack). The migration must decide whether to:
|
||||
|
||||
- Keep `NodeEntityId = number` and handle the string case at the bridge layer
|
||||
- Or define `NodeEntityId = number | string` with branding (less safe)
|
||||
|
||||
Recommend the former: the bridge layer coerces string IDs to a numeric
|
||||
mapping, and only branded numeric IDs enter the World.
|
||||
|
||||
### 1b. Component interfaces
|
||||
|
||||
Define component interfaces in `src/ecs/components/`:
|
||||
|
||||
```
|
||||
src/ecs/
|
||||
entityId.ts # Branded ID types
|
||||
components/
|
||||
position.ts # Position (shared by Node, Reroute, Group)
|
||||
nodeType.ts # NodeType
|
||||
nodeVisual.ts # NodeVisual
|
||||
connectivity.ts # Connectivity
|
||||
execution.ts # Execution
|
||||
properties.ts # Properties
|
||||
widgetContainer.ts # WidgetContainer
|
||||
linkEndpoints.ts # LinkEndpoints
|
||||
...
|
||||
world.ts # World type and factory
|
||||
```
|
||||
|
||||
Components are TypeScript interfaces only — no runtime code. They mirror
|
||||
the decomposition in ADR 0008 Section "Component Decomposition."
|
||||
|
||||
**Risk:** None. Interface-only files.
|
||||
|
||||
### 1c. World type
|
||||
|
||||
Define the World as a typed container:
|
||||
`src/types/widgetId.ts` ships the branded string `WidgetId`:
|
||||
|
||||
```ts
|
||||
interface World {
|
||||
nodes: Map<NodeEntityId, NodeComponents>
|
||||
links: Map<LinkEntityId, LinkComponents>
|
||||
widgets: Map<WidgetEntityId, WidgetComponents>
|
||||
slots: Map<SlotEntityId, SlotComponents>
|
||||
reroutes: Map<RerouteEntityId, RerouteComponents>
|
||||
groups: Map<GroupEntityId, GroupComponents>
|
||||
scopes: Map<GraphId, GraphId | null> // graph scope DAG (parent or null for root)
|
||||
|
||||
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
||||
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
||||
getComponent<C>(id: EntityId, component: ComponentKey<C>): C | undefined
|
||||
setComponent<C>(id: EntityId, component: ComponentKey<C>, data: C): void
|
||||
}
|
||||
type WidgetId = string & { readonly __brand: 'WidgetId' }
|
||||
```
|
||||
|
||||
Subgraphs are not a separate entity kind. A node with a `SubgraphStructure`
|
||||
component represents a subgraph. The `scopes` map tracks the graph nesting DAG.
|
||||
See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full
|
||||
model.
|
||||
Format: `graphId:nodeId:name`. A `parseWidgetId()` helper splits a `WidgetId`
|
||||
back into its `{ graphId, nodeId, name }` parts at store boundaries.
|
||||
|
||||
World scope is per workflow instance. Linked subgraph definitions can be reused
|
||||
The composite string key carries the structural relationship (graph -> node ->
|
||||
widget) directly in the key. There is no synthetic opaque number and no reverse
|
||||
lookup index.
|
||||
|
||||
**Consideration:** `NodeId = number | string`. The `string` branch exists for
|
||||
subgraph-related nodes (GroupNode hack). The `WidgetId` format stringifies the
|
||||
`nodeId` segment, so both numeric and string node IDs flow through unchanged.
|
||||
|
||||
### 1b. Plain-data store state shapes
|
||||
|
||||
Each dedicated store holds plain-data records for its concern — no methods on the
|
||||
records, behavior lives in store actions and composables. State shapes mirror the
|
||||
decomposition in ADR 0008 Section "Component Decomposition" (position, node type,
|
||||
node visual, connectivity, execution, properties, widget container, link
|
||||
endpoints).
|
||||
|
||||
**Risk:** None. Type-only definitions.
|
||||
|
||||
### 1c. Dedicated stores
|
||||
|
||||
Phase 1 end-state is a set of dedicated Pinia stores, one per concern, each
|
||||
keyed by its own composite string ID. Each store owns its data and exposes a
|
||||
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` |
|
||||
|
||||
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
|
||||
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
|
||||
is no shared `lastWidgetId` counter; identity comes from the composite key.
|
||||
|
||||
Store scope is per workflow instance. Linked subgraph definitions can be reused
|
||||
across instances, but mutable runtime state (widget values, execution state,
|
||||
selection/transient view state) remains instance-scoped through `graphId`.
|
||||
selection/transient view state) stays instance-scoped through `graphId` embedded
|
||||
in each composite key.
|
||||
|
||||
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
|
||||
persistence. The World exists but nothing populates it yet.
|
||||
Subgraphs are not a separate store. Subgraph nesting is tracked in
|
||||
`subgraphNavigationStore`. See
|
||||
[Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full model.
|
||||
|
||||
**Risk:** Low. New code, no integration points.
|
||||
**Risk:** Low. Stores are additive; integration happens in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Bridge Layer
|
||||
## Phase 2: Store Integration
|
||||
|
||||
Connects the legacy class instances to the World. Both old and new code can
|
||||
read entity state; writes still go through legacy classes.
|
||||
Connects the legacy class instances to the dedicated stores. Both old and new
|
||||
code can read entity state; writes for not-yet-migrated concerns still go through
|
||||
legacy classes.
|
||||
|
||||
### 2a. Read-only bridge for Position
|
||||
### 2a. Position reads through layoutStore
|
||||
|
||||
The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already
|
||||
extracts position data for nodes, links, and reroutes into Y.js CRDTs. The
|
||||
bridge reads from LayoutStore and populates the World's `Position` component.
|
||||
`layoutStore` (`src/renderer/core/layout/store/layoutStore.ts`) already extracts
|
||||
position data for nodes, links, and reroutes into Y.js CRDTs and is the source of
|
||||
truth for layout.
|
||||
|
||||
**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors
|
||||
them into the World. New code reads `world.getComponent(nodeId, Position)`;
|
||||
legacy code continues to read `node.pos` / LayoutStore directly.
|
||||
**Approach:** New code reads position via `layoutStore` queries (and
|
||||
`useLayoutMutations()` for writes); legacy code continues to read `node.pos`
|
||||
directly during the transition. No second copy of position data is introduced —
|
||||
`layoutStore` stays authoritative.
|
||||
|
||||
**Open question:** Should the World wrap the Y.js maps or maintain its own
|
||||
plain-data copy? Options:
|
||||
**Risk:** Medium. The legacy `node.pos` read path must stay consistent with
|
||||
`layoutStore` during the transition. Watch for stale reads during render.
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| ---------------------- | ------------------------------------- | ----------------------------------------------- |
|
||||
| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test |
|
||||
| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead |
|
||||
| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change |
|
||||
### 2b. Consolidate widget callers onto widgetValueStore ✅ Largely shipped (PR 12617)
|
||||
|
||||
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
|
||||
copy is cheap (position is small data). Revisit if sync overhead becomes
|
||||
measurable.
|
||||
`widgetValueStore` (`src/stores/widgetValueStore.ts`) holds widget state in
|
||||
plain records keyed by `WidgetId` (`graphId:nodeId:name`) and is the source of
|
||||
truth for widget values. PR 12617 reverted the earlier synthetic-numeric-ID
|
||||
bridge approach.
|
||||
|
||||
**Risk:** Medium. Introduces a sync point between two state systems. Must
|
||||
ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale
|
||||
position during render).
|
||||
**Remaining work:** Consolidate the remaining widget callers onto
|
||||
`widgetValueStore`. Reads use `getWidget(widgetId)` / `getNodeWidgets(graphId,
|
||||
nodeId)`; writes use `setValue(widgetId, value)`; `parseWidgetId()` recovers the
|
||||
`{ graphId, nodeId, name }` parts at boundaries.
|
||||
|
||||
### 2b. Read-only bridge for WidgetValue
|
||||
|
||||
WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget
|
||||
state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is
|
||||
the closest proto-ECS store.
|
||||
|
||||
**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into
|
||||
`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires
|
||||
assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
|
||||
**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`.
|
||||
|
||||
**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
**Risk:** Low. The store is well-structured and `WidgetId`-native; identity comes
|
||||
from the composite key with no separate lookup index.
|
||||
|
||||
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
|
||||
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
|
||||
source node/widget identity is preserved only as migration and diagnostic
|
||||
metadata.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
### 2c. Node metadata stores
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
reading from `LGraphNode` instances. These are simple property copies.
|
||||
Populate node-metadata records (node type, visual, properties, execution) by
|
||||
reading from `LGraphNode` instances. These are simple property copies into the
|
||||
relevant store.
|
||||
|
||||
**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge
|
||||
creates the corresponding entity in the World and populates its components.
|
||||
When a node is removed, the bridge deletes the entity.
|
||||
|
||||
The `incrementVersion()` method from Phase 0a becomes the hook point — when
|
||||
version increments, the bridge can re-sync changed components. (This is why
|
||||
centralizing version first matters.)
|
||||
**Approach:** When a node is added to the graph (`LGraph.add()`), the store
|
||||
records its metadata. When a node is removed, the store drops it. The
|
||||
`incrementVersion()` seam from Phase 0a is a candidate hook point for re-sync
|
||||
when changed.
|
||||
|
||||
**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove)
|
||||
without breaking existing behavior. The bridge is read-only (World mirrors
|
||||
classes, not the reverse), which limits blast radius.
|
||||
without breaking existing behavior. Stores mirror the classes during the
|
||||
transition, which limits blast radius.
|
||||
|
||||
### Bridge sunset criteria (applies to every Phase 2 bridge)
|
||||
### Store sunset criteria (applies to every Phase 2 concern)
|
||||
|
||||
A bridge can move from "transitional" to "removal candidate" only when:
|
||||
A legacy path can move from "transitional" to "removal candidate" only when:
|
||||
|
||||
- All production reads for that concern flow through World component queries.
|
||||
- All production writes for that concern flow through system APIs.
|
||||
- Serialization parity tests show no diff between legacy and World paths.
|
||||
- Extension compatibility tests pass without bridge-only fallback paths.
|
||||
- All production reads for that concern flow through store accessors.
|
||||
- All production writes for that concern flow through store actions.
|
||||
- Serialization parity tests show no diff between legacy and store-driven paths.
|
||||
- Extension compatibility tests pass without legacy-only fallback paths.
|
||||
|
||||
These criteria prevent the bridge from becoming permanent by default.
|
||||
These criteria prevent the dual path from becoming permanent by default.
|
||||
|
||||
### Bridge duration and maintenance controls
|
||||
### Dual-path duration and maintenance controls
|
||||
|
||||
To contain dual-path maintenance cost during Phases 2-4:
|
||||
|
||||
- Every bridge concern has a named owner and target sunset release.
|
||||
- Every PR touching bridge-covered data paths must include parity tests for both
|
||||
legacy and World-driven execution.
|
||||
- Bridge fallback usage is instrumented in integration/e2e and reviewed every
|
||||
milestone; upward trends block new bridge expansion.
|
||||
- Any bridge that misses its target sunset release requires an explicit risk
|
||||
- Every concern has a named owner and target sunset release.
|
||||
- Every PR touching store-covered data paths must include parity tests for both
|
||||
legacy and store-driven execution.
|
||||
- Legacy fallback usage is instrumented in integration/e2e and reviewed every
|
||||
milestone; upward trends block new dual-path expansion.
|
||||
- Any concern that misses its target sunset release requires an explicit risk
|
||||
review and revised removal plan.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Systems
|
||||
|
||||
Introduce system functions that operate on World data. Systems coexist with
|
||||
Introduce system functions that operate on store data. Systems coexist with
|
||||
legacy methods — they don't replace them yet.
|
||||
|
||||
### 3a. SerializationSystem (read-only)
|
||||
|
||||
A function `serializeFromWorld(world: World): SerializedGraph` that produces
|
||||
workflow JSON by querying World components. Run alongside the existing
|
||||
`LGraph.serialize()` in tests to verify equivalence.
|
||||
A function `serializeFromStores(): SerializedGraph` that produces workflow JSON
|
||||
by querying the dedicated stores. Run alongside the existing `LGraph.serialize()`
|
||||
in tests to verify equivalence.
|
||||
|
||||
**Why first:** Serialization is read-only and has a clear correctness check
|
||||
(output must match existing serialization). It exercises every component type
|
||||
and proves the World contains sufficient data.
|
||||
(output must match existing serialization). It exercises every store and proves
|
||||
the stores contain sufficient data.
|
||||
|
||||
**Risk:** Low. Runs in parallel with existing code; does not replace it.
|
||||
|
||||
### 3b. VersionSystem
|
||||
|
||||
Replace the `incrementVersion()` method with a system that owns all change
|
||||
tracking. The system observes component mutations on the World and
|
||||
auto-increments the version counter.
|
||||
Move change tracking behind a system that observes store mutations and
|
||||
auto-increments the version counter, replacing scattered explicit increment
|
||||
calls.
|
||||
|
||||
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
|
||||
doesn't see changes).
|
||||
**Dependency:** Requires Phase 2 store integration (otherwise the system doesn't
|
||||
see changes).
|
||||
|
||||
**Risk:** Medium. Must not miss any change that the scattered `_version++`
|
||||
currently catches. The 19-site inventory from Phase 0a serves as the test
|
||||
matrix.
|
||||
historically caught.
|
||||
|
||||
### 3c. ConnectivitySystem (queries only)
|
||||
|
||||
A system that can answer connectivity queries by reading `Connectivity`,
|
||||
`SlotConnection`, and `LinkEndpoints` components from the World:
|
||||
A system that answers connectivity queries by reading connectivity, slot, and
|
||||
link-endpoint records from the relevant stores:
|
||||
|
||||
- "What nodes are connected to this node's inputs?"
|
||||
- "What links pass through this reroute?"
|
||||
- "What is the execution order?"
|
||||
|
||||
Does not perform mutations yet — just queries. Validates that the World's
|
||||
connectivity data is complete and consistent with the class-based graph.
|
||||
Does not perform mutations yet — just queries. Validates that store connectivity
|
||||
data is complete and consistent with the class-based graph.
|
||||
|
||||
**Risk:** Low. Read-only system with equivalence tests.
|
||||
|
||||
@@ -326,27 +266,27 @@ connectivity data is complete and consistent with the class-based graph.
|
||||
|
||||
## Phase 4: Write Path Migration
|
||||
|
||||
Systems begin owning mutations. Legacy class methods delegate to systems.
|
||||
This is the highest-risk phase.
|
||||
Systems begin owning mutations. Legacy class methods delegate to stores and
|
||||
systems. This is the highest-risk phase.
|
||||
|
||||
### 4a. Position writes through World
|
||||
### 4a. Position writes through layoutStore
|
||||
|
||||
New code writes position via `world.setComponent(nodeId, Position, ...)`.
|
||||
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
|
||||
New code writes position via `useLayoutMutations()` against `layoutStore`. A
|
||||
compatibility shim propagates changes back to `LGraphNode.pos` for legacy
|
||||
readers.
|
||||
|
||||
**This inverts the data flow:** Phase 2 had legacy -> World (read bridge).
|
||||
Phase 4 has World -> legacy (write bridge). Both paths must work during the
|
||||
transition.
|
||||
**This inverts the data flow:** Phase 2 had legacy -> store (read path). Phase 4
|
||||
has store -> legacy (write path). Both must work during the transition.
|
||||
|
||||
**Risk:** High. Two-way sync between World and legacy state. Must handle
|
||||
re-entrant updates (World write triggers bridge, which writes to legacy,
|
||||
which must NOT trigger another World write).
|
||||
**Risk:** High. Two-way sync between `layoutStore` and legacy state. Must handle
|
||||
re-entrant updates (store write triggers the shim, which writes to legacy, which
|
||||
must NOT trigger another store write).
|
||||
|
||||
### 4b. ConnectivitySystem mutations
|
||||
|
||||
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
|
||||
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
|
||||
system.
|
||||
functions over the connectivity stores. Legacy `LGraphNode.connect()` etc.
|
||||
delegate to the system.
|
||||
|
||||
**Extension API concern:** The current system fires callbacks at each step:
|
||||
|
||||
@@ -363,8 +303,8 @@ the system knowing about the callback API.
|
||||
|
||||
**Phase 4 callback contract (locked):**
|
||||
|
||||
- `onConnectOutput()` and `onConnectInput()` run before any World mutation.
|
||||
- If either callback rejects, abort with no component writes, no version bump,
|
||||
- `onConnectOutput()` and `onConnectInput()` run before any store mutation.
|
||||
- If either callback rejects, abort with no store writes, no version bump,
|
||||
and no lifecycle events.
|
||||
- `onConnectionsChange()` fires synchronously after commit, preserving current
|
||||
source-then-target ordering.
|
||||
@@ -374,14 +314,14 @@ the system knowing about the callback API.
|
||||
**Risk:** High. Extensions depend on callback ordering and timing. Must be
|
||||
validated against real-world extensions.
|
||||
|
||||
### 4c. Widget write path
|
||||
### 4c. Widget write path ✅ Largely shipped (PR 12617)
|
||||
|
||||
Widget value changes go through the World instead of directly through
|
||||
WidgetValueStore. The World's `WidgetValue` component becomes the single
|
||||
source of truth; WidgetValueStore becomes a read-through cache or is removed.
|
||||
`widgetValueStore.setValue()` is already the widget write path and the source of
|
||||
truth for widget values. Remaining work routes the last legacy widget writers
|
||||
through `setValue()` rather than mutating widget instances directly.
|
||||
|
||||
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
|
||||
change is routing writes through the World instead of the store.
|
||||
**Risk:** Medium. The store is well-abstracted and `WidgetId`-native. The main
|
||||
change is migrating the remaining direct-mutation call sites onto `setValue()`.
|
||||
|
||||
### 4d. Layout write path and render decoupling
|
||||
|
||||
@@ -407,25 +347,25 @@ Before enabling ECS render reads as default for any migrated family:
|
||||
- Compare legacy vs ECS p95 frame time and mean draw cost.
|
||||
- Block rollout on statistically significant regression beyond agreed budget
|
||||
(default budget: 5% p95 frame-time regression ceiling).
|
||||
- Capture profiler traces proving the dominant cost is not repeated
|
||||
`world.getComponent()` lookups.
|
||||
- Capture profiler traces proving the dominant cost is not repeated store
|
||||
accessor lookups.
|
||||
|
||||
### Phase 3 -> 4 gate (required)
|
||||
|
||||
Phase 4 starts only when all of the following are true:
|
||||
|
||||
- A transaction wrapper API exists on the World and is used by connectivity and
|
||||
widget write paths in integration tests.
|
||||
- A store/command-executor transaction wrapper exists and is used by connectivity
|
||||
and widget write paths in integration tests.
|
||||
- Undo batching parity is proven: one logical user action yields one undo
|
||||
checkpoint in both legacy and ECS paths.
|
||||
checkpoint in both legacy and store-driven paths.
|
||||
- Callback timing and rejection semantics from Phase 4b are covered by
|
||||
integration tests.
|
||||
- A representative extension suite passes, including `rgthree-comfy`.
|
||||
- Write bridge re-entrancy tests prove there is no World <-> legacy feedback
|
||||
- Write-path re-entrancy tests prove there is no store <-> legacy feedback
|
||||
loop.
|
||||
- Layout migration for any enabled node family passes read-only render checks
|
||||
(no `arrange()` writes during draw).
|
||||
- Render hot-path benchmark gate passes for every family moving to ECS-first
|
||||
- Render hot-path benchmark gate passes for every family moving to store-first
|
||||
reads.
|
||||
|
||||
---
|
||||
@@ -435,10 +375,11 @@ Phase 4 starts only when all of the following are true:
|
||||
Remove bridge layers and deprecated class properties. This phase happens
|
||||
per-component, not all at once.
|
||||
|
||||
### 5a. Remove Position bridge
|
||||
### 5a. Remove Position compatibility shim
|
||||
|
||||
Once all position reads and writes go through the World, remove the bridge
|
||||
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
|
||||
Once all position reads and writes go through `layoutStore`, remove the
|
||||
compatibility shim and the `pos`/`size` properties from `LGraphNode`, `Reroute`,
|
||||
`LGraphGroup`.
|
||||
|
||||
### 5b. Remove widget class hierarchy
|
||||
|
||||
@@ -448,9 +389,9 @@ replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
|
||||
|
||||
### 5c. Dissolve god objects
|
||||
|
||||
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is
|
||||
holding the entity ID and delegating to the World. Eventually, they can be
|
||||
removed entirely, replaced by entity ID + component queries.
|
||||
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is holding
|
||||
the composite ID and delegating to the stores. Eventually, they can be removed
|
||||
entirely, replaced by composite IDs + store queries.
|
||||
|
||||
**Risk:** Very High. This is the irreversible step. Must be done only after
|
||||
thorough validation that all consumers (including extensions) work with the
|
||||
@@ -460,8 +401,8 @@ ECS path.
|
||||
|
||||
Legacy removal starts only when all of the following are true:
|
||||
|
||||
- The component being removed has no remaining direct reads or writes outside
|
||||
World/system APIs.
|
||||
- The concern being removed has no remaining direct reads or writes outside
|
||||
store/system APIs.
|
||||
- Serialization equivalence tests pass continuously for one release cycle.
|
||||
- A representative extension compatibility matrix is green, including
|
||||
`rgthree-comfy`.
|
||||
@@ -489,20 +430,20 @@ The team prepares a single go/no-go packet containing:
|
||||
|
||||
### CRDT / ECS coexistence
|
||||
|
||||
The LayoutStore uses Y.js CRDTs for collaboration-ready position data
|
||||
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World
|
||||
uses plain `Map`s. These must coexist.
|
||||
`layoutStore` uses Y.js CRDTs for collaboration-ready position data
|
||||
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The other dedicated
|
||||
stores hold plain reactive data. These must coexist.
|
||||
|
||||
**Options explored in Phase 2a.** The recommended path (World copies from Y.js)
|
||||
defers the hard question. Eventually, the World may need to be CRDT-native —
|
||||
but this requires a separate ADR.
|
||||
`layoutStore` stays authoritative for layout (Phase 2a), so position data has a
|
||||
single CRDT-backed home. Whether other stores need CRDT backing is open and
|
||||
requires a separate ADR.
|
||||
|
||||
**Questions to resolve:**
|
||||
|
||||
- Should non-position components also be CRDT-backed for collaboration?
|
||||
- Does the World need an operation log for undo/redo, or can that remain
|
||||
external (Y.js undo manager)?
|
||||
- How does conflict resolution work when two users modify the same component?
|
||||
- Should non-position stores also be CRDT-backed for collaboration?
|
||||
- Do the stores need an operation log for undo/redo, or can that remain external
|
||||
(Y.js undo manager)?
|
||||
- How does conflict resolution work when two users modify the same record?
|
||||
|
||||
### Extension API preservation
|
||||
|
||||
@@ -529,7 +470,7 @@ event listeners instead of callbacks.
|
||||
|
||||
**Phase 4 decisions:**
|
||||
|
||||
- Rejection callbacks act as pre-commit guards (reject before World mutation).
|
||||
- Rejection callbacks act as pre-commit guards (reject before store mutation).
|
||||
- Callback dispatch remains synchronous during the bridge period.
|
||||
- Callback order remains: output validation -> input validation -> commit ->
|
||||
output change notification -> input change notification.
|
||||
@@ -546,16 +487,12 @@ incrementally to ECS-native patterns.
|
||||
const seedWidget = node.widgets?.find((w) => w.name === 'seed')
|
||||
seedWidget?.setValue(42)
|
||||
|
||||
// ECS pattern (using the bridge/world widget lookup index)
|
||||
const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed')
|
||||
// Store pattern (composite WidgetId, no reverse-lookup index needed)
|
||||
const seedWidgetId = widgetValueStore
|
||||
.getNodeWidgets(graphId, nodeId)
|
||||
.find((id) => parseWidgetId(id).name === 'seed')
|
||||
if (seedWidgetId) {
|
||||
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
|
||||
if (widgetValue) {
|
||||
world.setComponent(seedWidgetId, WidgetValue, {
|
||||
...widgetValue,
|
||||
value: 42
|
||||
})
|
||||
}
|
||||
widgetValueStore.setValue(seedWidgetId, 42)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -606,17 +543,15 @@ lifecycleEvents.on('entity.removed', (event) => {
|
||||
// Legacy pattern (do not add new usages)
|
||||
graph._version++
|
||||
|
||||
// Bridge-safe transitional pattern (Phase 0a)
|
||||
// Transitional pattern (Phase 0a)
|
||||
graph.incrementVersion()
|
||||
|
||||
// ECS-native pattern: mutate through command/system API.
|
||||
// Store-native pattern: mutate through the command/system API.
|
||||
// VersionSystem bumps once at transaction commit.
|
||||
executor.run({
|
||||
type: 'SetWidgetValue',
|
||||
execute(world) {
|
||||
const value = world.getComponent(widgetId, WidgetValue)
|
||||
if (!value) return
|
||||
world.setComponent(widgetId, WidgetValue, { ...value, value: 42 })
|
||||
execute() {
|
||||
widgetValueStore.setValue(widgetId, 42)
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -628,9 +563,11 @@ executor.run({
|
||||
|
||||
### Atomicity and transactions
|
||||
|
||||
The ECS lifecycle scenarios claim operations are "atomic." This requires
|
||||
the World to support transactions — the ability to batch multiple component
|
||||
writes and commit or rollback as a unit.
|
||||
The lifecycle scenarios claim operations are "atomic." This requires a
|
||||
store/command-executor transaction — the ability to batch multiple store writes
|
||||
and commit or rollback as a unit. `layoutStore` already wraps its mutations in
|
||||
Y.js transactions; the command executor extends the same discipline across
|
||||
stores.
|
||||
|
||||
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
|
||||
checkpoints but not true transactions. The graph can be in an inconsistent
|
||||
@@ -638,10 +575,10 @@ state between these calls.
|
||||
|
||||
**Phase 4 baseline semantics:**
|
||||
|
||||
- Mutating systems run inside `world.transaction(label, fn)`.
|
||||
- The bridge maps one World transaction to one `beforeChange()` /
|
||||
- Mutating systems run inside a single command-executor transaction.
|
||||
- The bridge maps one executor transaction to one `beforeChange()` /
|
||||
`afterChange()` bracket.
|
||||
- Operations with multiple component writes (for example `connect()` touching
|
||||
- Operations with multiple store writes (for example `connect()` touching
|
||||
slots, links, and node metadata) still commit as one transaction and therefore
|
||||
one undo entry.
|
||||
- Failed transactions do not publish partial writes, lifecycle events, or
|
||||
@@ -649,65 +586,64 @@ state between these calls.
|
||||
|
||||
**Questions to resolve:**
|
||||
|
||||
- How should `world.transaction()` interact with Y.js transactions when a
|
||||
component is CRDT-backed?
|
||||
- How should the command-executor transaction interact with the Y.js
|
||||
transactions that `layoutStore` already runs?
|
||||
- Is eventual consistency acceptable for derived data updates between
|
||||
transactions, or must post-transaction state always be immediately
|
||||
consistent?
|
||||
|
||||
### Keying strategy unification
|
||||
|
||||
The 6 proto-ECS stores use 6 different keying strategies:
|
||||
The dedicated stores use per-concern keying strategies:
|
||||
|
||||
| Store | Key Format |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
|
||||
| DomWidgetStore | Widget UUID |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
source node/widget identity.
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
`WidgetEntityId`s replace this with an opaque number, requiring a reverse
|
||||
lookup index.
|
||||
Composite string keys won over synthetic numeric IDs. A widget is identified by
|
||||
its relationship to a graph and node, and the `graphId:nodeId:name` key carries
|
||||
that relationship directly. PR 12617 kept the composite string instead of an
|
||||
opaque number, so no reverse lookup index is required — `parseWidgetId()`
|
||||
recovers the parts on demand.
|
||||
|
||||
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
|
||||
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
|
||||
for the transition period.
|
||||
**Resolution:** Self-documenting composite keys, parsed at boundaries. Each store
|
||||
keeps the key format that matches its concern; there is no forced unification
|
||||
under a single ID space.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 0a (incrementVersion) ──┐
|
||||
Phase 0b (ID type aliases) ───┤
|
||||
Phase 0a (incrementVersion) ──── ✅ shipped (one stray cleanup remaining)
|
||||
Phase 0b (ID type aliases) ───┐
|
||||
Phase 0c (doc fixes) ─────────┤── no dependencies between these
|
||||
│
|
||||
Phase 1a (branded IDs) ────────┤
|
||||
Phase 1b (component interfaces) ┤── 1b depends on 1a
|
||||
Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b
|
||||
|
||||
Phase 2a (Position bridge) ────┐── depends on 1c
|
||||
Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c
|
||||
Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c
|
||||
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
|
||||
Phase 1b (store state shapes) ─┐── depends on 1a
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
|
||||
|
||||
Phase 2a (Position via layoutStore) ─┐── depends on 1c
|
||||
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
|
||||
Phase 2c (Node metadata stores) ────┘── depends on 1c
|
||||
|
||||
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
|
||||
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
|
||||
Phase 3b (VersionSystem) ──────── depends on 2c (store-level change tracking)
|
||||
Phase 3c (ConnectivitySystem) ──── depends on 2c
|
||||
|
||||
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
|
||||
|
||||
Phase 4a (Position writes) ────── depends on 2a, 3b
|
||||
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
|
||||
Phase 4c (Widget writes) ─────── depends on 2b
|
||||
Phase 4c (Widget writes) ─────── ✅ largely shipped; depends on 2b
|
||||
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
|
||||
|
||||
Phase 4->5 exit criteria ──────── depends on all of Phase 4
|
||||
@@ -715,16 +651,19 @@ Phase 4->5 exit criteria ──────── depends on all of Phase 4
|
||||
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
|
||||
```
|
||||
|
||||
The dedicated stores (1c) are the hub: Phase 2 routes legacy data into them,
|
||||
Phase 3 systems read from them, Phase 4 routes writes through them.
|
||||
|
||||
## Risk Summary
|
||||
|
||||
| Phase | Risk | Reversibility | Extension Impact |
|
||||
| ------------------ | ---------- | ----------------------- | --------------------------- |
|
||||
| 0 (Foundation) | None | Fully reversible | None |
|
||||
| 1 (Types/World) | Low | New files, deletable | None |
|
||||
| 2 (Bridge) | Low-Medium | Bridge is additive | None |
|
||||
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
|
||||
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
|
||||
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
|
||||
| Phase | Risk | Reversibility | Extension Impact |
|
||||
| --------------------- | ---------- | ----------------------- | --------------------------- |
|
||||
| 0 (Foundation) | None | Fully reversible | None |
|
||||
| 1 (Types/Stores) | Low | New files, deletable | None |
|
||||
| 2 (Store integration) | Low-Medium | Additive store reads | None |
|
||||
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
|
||||
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
|
||||
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
|
||||
|
||||
The plan is designed so that Phases 0-3 can ship without any risk to
|
||||
extensions or existing behavior. Phase 4 is where the real migration begins,
|
||||
|
||||
@@ -2,30 +2,29 @@
|
||||
|
||||
This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the [current system](entity-interactions.md) transform under ECS, and how the [structural problems](entity-problems.md) are resolved. For the full design rationale, see [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## 1. World Overview
|
||||
## 1. Store Overview
|
||||
|
||||
The World is the single source of truth for runtime entity state in one
|
||||
workflow instance. Entities are just branded IDs. Components are plain data
|
||||
objects. Systems are functions that query the World.
|
||||
The source of truth for runtime entity state in one workflow instance is the set
|
||||
of dedicated Pinia stores. Each store is keyed by per-store string IDs.
|
||||
Components are plain data objects. Systems are functions that query the relevant
|
||||
store(s).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph World["World (Central Registry)"]
|
||||
subgraph Stores["Dedicated Stores (source of truth)"]
|
||||
direction TB
|
||||
NodeStore["Nodes
|
||||
Map<NodeEntityId, NodeComponents>"]
|
||||
LinkStore["Links
|
||||
Map<LinkEntityId, LinkComponents>"]
|
||||
ScopeRegistry["Graph Scopes
|
||||
Map<GraphId, ParentGraphId | null>"]
|
||||
WidgetStore["Widgets
|
||||
Map<WidgetEntityId, WidgetComponents>"]
|
||||
SlotStore["Slots
|
||||
Map<SlotEntityId, SlotComponents>"]
|
||||
RerouteStore["Reroutes
|
||||
Map<RerouteEntityId, RerouteComponents>"]
|
||||
GroupStore["Groups
|
||||
Map<GroupEntityId, GroupComponents>"]
|
||||
WidgetValueStore["widgetValueStore
|
||||
Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
LayoutStore["layoutStore (Y.js CRDT)
|
||||
nodeId / linkId / rerouteId → layout"]
|
||||
NodeOutputStore["nodeOutputStore
|
||||
Map<nodeLocatorId, outputs>"]
|
||||
SubgraphNavStore["subgraphNavigationStore
|
||||
active subgraph path"]
|
||||
PreviewExposureStore["previewExposureStore
|
||||
preview exposure state"]
|
||||
end
|
||||
|
||||
subgraph Systems["Systems (Behavior)"]
|
||||
@@ -38,53 +37,50 @@ Map<GroupEntityId, GroupComponents>"]
|
||||
VS["VersionSystem"]
|
||||
end
|
||||
|
||||
RS -->|reads| World
|
||||
SS -->|reads/writes| World
|
||||
CS -->|reads/writes| World
|
||||
LS -->|reads/writes| World
|
||||
ES -->|reads| World
|
||||
VS -->|reads/writes| World
|
||||
RS -->|reads| Stores
|
||||
SS -->|reads/writes| Stores
|
||||
CS -->|reads/writes| LayoutStore
|
||||
LS -->|reads/writes| LayoutStore
|
||||
ES -->|reads| NodeOutputStore
|
||||
VS -->|reads/writes| LayoutStore
|
||||
|
||||
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
|
||||
style Stores fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
|
||||
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
|
||||
```
|
||||
|
||||
### Entity IDs
|
||||
### Entity Keys
|
||||
|
||||
Each store addresses entities by its own string-key convention.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Branded IDs (compile-time distinct)"
|
||||
NID["NodeEntityId
|
||||
number & { __brand: 'NodeEntityId' }"]
|
||||
LID["LinkEntityId
|
||||
number & { __brand: 'LinkEntityId' }"]
|
||||
WID["WidgetEntityId
|
||||
number & { __brand: 'WidgetEntityId' }"]
|
||||
SLID["SlotEntityId
|
||||
number & { __brand: 'SlotEntityId' }"]
|
||||
RID["RerouteEntityId
|
||||
number & { __brand: 'RerouteEntityId' }"]
|
||||
GID["GroupEntityId
|
||||
number & { __brand: 'GroupEntityId' }"]
|
||||
subgraph "Per-store string keys"
|
||||
WID["WidgetId
|
||||
graphId:nodeId:name
|
||||
(branded string, src/types/widgetId.ts)"]
|
||||
NLID["nodeLocatorId
|
||||
subgraphId:nodeId"]
|
||||
NID["nodeId (raw)"]
|
||||
LID["linkId (raw)"]
|
||||
RID["rerouteId (raw)"]
|
||||
end
|
||||
|
||||
GRID["GraphId
|
||||
string & { __brand: 'GraphId' }"]:::scopeId
|
||||
|
||||
NID -.-x LID
|
||||
LID -.-x WID
|
||||
WID -.-x SLID
|
||||
|
||||
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
|
||||
|
||||
linkStyle 0 stroke:red,stroke-dasharray:5
|
||||
linkStyle 1 stroke:red,stroke-dasharray:5
|
||||
linkStyle 2 stroke:red,stroke-dasharray:5
|
||||
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
|
||||
NLID -->|nodeOutputStore| W
|
||||
NID -->|layoutStore| W
|
||||
LID -->|layoutStore| W
|
||||
RID -->|layoutStore| W
|
||||
```
|
||||
|
||||
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
|
||||
`WidgetId = graphId:nodeId:name` is itself a branded string (see
|
||||
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
|
||||
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
|
||||
`rerouteId`. Each store enforces its own key shape; there is no single shared
|
||||
entity-ID type across stores.
|
||||
|
||||
Note: `GraphId` is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
|
||||
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
|
||||
`SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
|
||||
|
||||
### Linked subgraphs and instance-varying state
|
||||
|
||||
@@ -94,9 +90,10 @@ instance-scoped.
|
||||
- Shared definition-level data (interface shape, default metadata) can be reused
|
||||
across instances.
|
||||
- Runtime state (`WidgetValue`, execution/transient state, selection) is scoped
|
||||
to the containing `graphId` chain inside one World instance.
|
||||
- "Single source of truth" therefore means one source per workflow instance,
|
||||
not one global source across all linked instances.
|
||||
to the containing `graphId` chain inside one workflow instance.
|
||||
- "Single source of truth" therefore means one source per workflow instance
|
||||
across the dedicated stores, with no global source shared across all linked
|
||||
instances.
|
||||
|
||||
### Recursive subgraphs without inheritance
|
||||
|
||||
@@ -130,7 +127,7 @@ graph LR
|
||||
B12["connect(), disconnect()"]
|
||||
end
|
||||
|
||||
subgraph After["NodeEntityId + Components"]
|
||||
subgraph After["nodeId-keyed components (across stores)"]
|
||||
direction TB
|
||||
A1["Position
|
||||
{ pos, size, bounding }"]
|
||||
@@ -180,7 +177,7 @@ target_id, target_slot, type"]
|
||||
B5["resolve()"]
|
||||
end
|
||||
|
||||
subgraph After["LinkEntityId + Components"]
|
||||
subgraph After["linkId-keyed components (layoutStore)"]
|
||||
direction TB
|
||||
A1["LinkEndpoints
|
||||
{ originId, originSlot,
|
||||
@@ -214,7 +211,7 @@ graph LR
|
||||
B5["useWidgetValueStore()"]
|
||||
end
|
||||
|
||||
subgraph After["WidgetEntityId + Components"]
|
||||
subgraph After["WidgetId + components"]
|
||||
direction TB
|
||||
A1["WidgetIdentity
|
||||
{ name, widgetType, parentNodeId }"]
|
||||
@@ -228,8 +225,7 @@ graph LR
|
||||
B2 -.-> A2
|
||||
B3 -.-> A3
|
||||
B4 -.->|"moves to"| SYS1["RenderSystem"]
|
||||
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
|
||||
B6 -.->|"moves to"| SYS3["PromotionSystem"]
|
||||
B5 -.->|"absorbed by"| SYS2["widgetValueStore"]
|
||||
|
||||
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
@@ -237,7 +233,7 @@ graph LR
|
||||
|
||||
## 3. System Architecture
|
||||
|
||||
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
|
||||
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Each system owns exactly one concern.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -389,30 +385,25 @@ graph TD
|
||||
VS["VersionSystem"]
|
||||
end
|
||||
|
||||
World["World
|
||||
(instance-scoped source of truth)"]
|
||||
|
||||
subgraph Components["Component Stores"]
|
||||
Pos["Position"]
|
||||
Vis["*Visual"]
|
||||
Con["Connectivity"]
|
||||
Val["*Value"]
|
||||
subgraph Stores["Dedicated Stores (instance-scoped source of truth)"]
|
||||
LayoutStore["layoutStore"]
|
||||
WidgetValueStore["widgetValueStore"]
|
||||
DomWidgetStore["domWidgetStore"]
|
||||
NodeOutputStore["nodeOutputStore"]
|
||||
end
|
||||
|
||||
Systems -->|"query/mutate"| World
|
||||
World -->|"contains"| Components
|
||||
Systems -->|"query/mutate"| Stores
|
||||
|
||||
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
|
||||
style Components fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
|
||||
style Stores fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- **No circular dependencies**: entities are IDs, not class instances
|
||||
- **No Demeter violations**: systems query the World directly, never reach through entities
|
||||
- **No scattered store access**: the World _is_ the store; systems are the only writers
|
||||
- **Unidirectional**: Input → Systems → World → Render (no back-edges)
|
||||
- **No Demeter violations**: systems query stores directly, never reach through entities
|
||||
- **Data lives in dedicated stores**: systems are the only writers
|
||||
- **Unidirectional**: Input → Systems → Stores → Render (no back-edges)
|
||||
- **Instance safety**: linked definitions can be reused without forcing shared
|
||||
mutable widget/execution state across instances
|
||||
|
||||
@@ -447,9 +438,10 @@ No inheritance hierarchy.
|
||||
Subgraph = node + component."]
|
||||
S3["One system per concern.
|
||||
Systems don't overlap."]
|
||||
S4["Branded per-kind IDs.
|
||||
Compile-time type errors."]
|
||||
S5["Systems query World.
|
||||
S4["Consistent per-store
|
||||
string-key conventions
|
||||
(WidgetId, nodeLocatorId, raw ids)."]
|
||||
S5["Systems query stores.
|
||||
No entity→entity refs."]
|
||||
S6["VersionSystem owns
|
||||
all change tracking."]
|
||||
@@ -479,30 +471,30 @@ sequenceDiagram
|
||||
participant Legacy as Legacy Code
|
||||
participant Class as LGraphNode (class)
|
||||
participant Bridge as Bridge Adapter
|
||||
participant World as World (ECS)
|
||||
participant Store as layoutStore (ECS)
|
||||
participant New as New Code / Systems
|
||||
|
||||
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
|
||||
Note over Legacy,New: Phase 1: Bridge reads from class, writes to store
|
||||
|
||||
Legacy->>Class: node.pos = [100, 200]
|
||||
Class->>Bridge: pos setter intercepted
|
||||
Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
|
||||
Bridge->>Store: useLayoutMutations().moveNode(nodeId, { pos: [100, 200] })
|
||||
|
||||
New->>World: world.getComponent(nodeId, Position)
|
||||
World-->>New: { pos: [100, 200], size: [...] }
|
||||
New->>Store: layoutStore read for nodeId
|
||||
Store-->>New: { pos: [100, 200], size: [...] }
|
||||
|
||||
Note over Legacy,New: Phase 2: New features build on ECS directly
|
||||
|
||||
New->>World: world.setComponent(nodeId, Position, { pos: [150, 250] })
|
||||
World->>Bridge: change detected
|
||||
New->>Store: useLayoutMutations().moveNode(nodeId, { pos: [150, 250] })
|
||||
Store->>Bridge: change detected
|
||||
Bridge->>Class: node._pos = [150, 250]
|
||||
Legacy->>Class: node.pos
|
||||
Class-->>Legacy: [150, 250]
|
||||
|
||||
Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed
|
||||
|
||||
New->>World: world.getComponent(nodeId, Position)
|
||||
World-->>New: { pos: [150, 250] }
|
||||
New->>Store: layoutStore read for nodeId
|
||||
Store-->>New: { pos: [150, 250] }
|
||||
```
|
||||
|
||||
### Incremental layout/render separation
|
||||
@@ -524,16 +516,17 @@ incremental rollout safety.
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Phase1["Phase 1: Types Only"]
|
||||
T1["Define branded IDs"]
|
||||
T1["Define string-key types
|
||||
(WidgetId, nodeLocatorId)"]
|
||||
T2["Define component interfaces"]
|
||||
T3["Define World type"]
|
||||
T3["Define store shapes"]
|
||||
end
|
||||
|
||||
subgraph Phase2["Phase 2: Bridge"]
|
||||
B1["Bridge adapters
|
||||
class ↔ World sync"]
|
||||
class ↔ store sync"]
|
||||
B2["New features use
|
||||
World as source"]
|
||||
stores as source"]
|
||||
B3["Old code unchanged"]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# World API and Command Layer
|
||||
|
||||
How the ECS World's imperative API relates to ADR 0003's command pattern
|
||||
requirement, and why the two are complementary rather than conflicting.
|
||||
|
||||
This document responds to the concern that `world.setComponent()` and
|
||||
`ConnectivitySystem.connect()` are "imperative mutators" incompatible with
|
||||
serializable, idempotent commands. The short answer: they are the
|
||||
**implementation** of commands, not a replacement for them.
|
||||
|
||||
## Architectural Layering
|
||||
|
||||
```
|
||||
Caller → Command → System (handler) → World (store) → Y.js (sync)
|
||||
↓
|
||||
Command Log (undo, replay, sync)
|
||||
```
|
||||
|
||||
- **Commands** describe intent. They are serializable, deterministic, and
|
||||
idempotent.
|
||||
- **Systems** are command handlers. They validate, execute, and emit lifecycle
|
||||
events.
|
||||
- **The World** is the store. It holds component data. It does not know about
|
||||
commands.
|
||||
|
||||
This is the same relationship Redux has between actions, reducers, and the
|
||||
store. The store's `dispatch()` is imperative. That does not make Redux
|
||||
incompatible with serializable actions.
|
||||
|
||||
## Proposed World Mutation API
|
||||
|
||||
The World exposes a thin imperative surface. Every mutation goes through a
|
||||
system, and every system call is invoked by a command.
|
||||
|
||||
### World Core API
|
||||
|
||||
```ts
|
||||
interface World {
|
||||
// Reads (no command needed)
|
||||
getComponent<C>(id: EntityId, key: ComponentKey<C>): C | undefined
|
||||
hasComponent(id: EntityId, key: ComponentKey<C>): boolean
|
||||
queryAll<C extends ComponentKey[]>(...keys: C): QueryResult<C>[]
|
||||
|
||||
// Mutations (called only by systems, inside transactions)
|
||||
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
||||
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
||||
setComponent<C>(id: EntityId, key: ComponentKey<C>, data: C): void
|
||||
removeComponent(id: EntityId, key: ComponentKey<C>): void
|
||||
|
||||
// Transaction boundary
|
||||
transaction<T>(label: string, fn: () => T): T
|
||||
}
|
||||
```
|
||||
|
||||
These methods are **internal**. External callers never call
|
||||
`world.setComponent()` directly — they submit commands.
|
||||
|
||||
### Command Interface
|
||||
|
||||
```ts
|
||||
interface Command<T = void> {
|
||||
readonly type: string
|
||||
execute(world: World): T
|
||||
}
|
||||
```
|
||||
|
||||
A command is a plain object with a `type` discriminator and an `execute`
|
||||
method that receives the World. The command executor wraps every
|
||||
`execute()` call in a World transaction.
|
||||
|
||||
### Command Executor
|
||||
|
||||
```ts
|
||||
interface CommandExecutor {
|
||||
run<T>(command: Command<T>): T
|
||||
batch(label: string, commands: Command[]): void
|
||||
}
|
||||
|
||||
function createCommandExecutor(world: World): CommandExecutor {
|
||||
return {
|
||||
run(command) {
|
||||
return world.transaction(command.type, () => command.execute(world))
|
||||
},
|
||||
batch(label, commands) {
|
||||
world.transaction(label, () => {
|
||||
for (const cmd of commands) cmd.execute(world)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every command execution:
|
||||
|
||||
1. Opens a World transaction (maps to one `beforeChange`/`afterChange`
|
||||
bracket for undo).
|
||||
2. Calls the command's `execute()`, which invokes system functions.
|
||||
3. Commits the transaction. On failure, rolls back — no partial writes, no
|
||||
lifecycle events, no version bump.
|
||||
|
||||
## From Imperative Calls to Commands
|
||||
|
||||
The lifecycle scenarios in
|
||||
[ecs-lifecycle-scenarios.md](ecs-lifecycle-scenarios.md) show system calls
|
||||
like `ConnectivitySystem.connect(world, outputSlotId, inputSlotId)`. These
|
||||
are the **internals** of a command. Here is how each scenario maps:
|
||||
|
||||
### Connect Slots
|
||||
|
||||
The lifecycle scenario shows:
|
||||
|
||||
```ts
|
||||
// Inside ConnectivitySystem — this is the handler, not the public API
|
||||
ConnectivitySystem.connect(world, outputSlotId, inputSlotId)
|
||||
```
|
||||
|
||||
The public API is a command:
|
||||
|
||||
```ts
|
||||
const connectSlots: Command = {
|
||||
type: 'ConnectSlots',
|
||||
outputSlotId,
|
||||
inputSlotId,
|
||||
|
||||
execute(world) {
|
||||
ConnectivitySystem.connect(world, this.outputSlotId, this.inputSlotId)
|
||||
}
|
||||
}
|
||||
|
||||
executor.run(connectSlots)
|
||||
```
|
||||
|
||||
The command object is serializable (`{ type, outputSlotId, inputSlotId }`).
|
||||
It can be sent over a wire, stored in a log, or replayed.
|
||||
|
||||
### Move Node
|
||||
|
||||
```ts
|
||||
const moveNode: Command = {
|
||||
type: 'MoveNode',
|
||||
nodeId,
|
||||
pos: [150, 250],
|
||||
|
||||
execute(world) {
|
||||
LayoutSystem.moveNode(world, this.nodeId, this.pos)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Node
|
||||
|
||||
```ts
|
||||
const removeNode: Command = {
|
||||
type: 'RemoveNode',
|
||||
nodeId,
|
||||
|
||||
execute(world) {
|
||||
ConnectivitySystem.removeNode(world, this.nodeId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Set Widget Value
|
||||
|
||||
```ts
|
||||
const setWidgetValue: Command = {
|
||||
type: 'SetWidgetValue',
|
||||
widgetId,
|
||||
value,
|
||||
|
||||
execute(world) {
|
||||
world.setComponent(this.widgetId, WidgetValue, {
|
||||
...world.getComponent(this.widgetId, WidgetValue)!,
|
||||
value: this.value
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Batch: Paste
|
||||
|
||||
Paste is a compound operation — many entities created in one undo step:
|
||||
|
||||
```ts
|
||||
const paste: Command = {
|
||||
type: 'Paste',
|
||||
snapshot,
|
||||
offset,
|
||||
|
||||
execute(world) {
|
||||
const remap = new Map<EntityId, EntityId>()
|
||||
|
||||
for (const entity of this.snapshot.entities) {
|
||||
const newId = world.createEntity(entity.kind)
|
||||
remap.set(entity.id, newId)
|
||||
|
||||
for (const [key, data] of entity.components) {
|
||||
world.setComponent(newId, key, remapEntityRefs(data, remap))
|
||||
}
|
||||
}
|
||||
|
||||
// Offset positions
|
||||
for (const [, newId] of remap) {
|
||||
const pos = world.getComponent(newId, Position)
|
||||
if (pos) {
|
||||
world.setComponent(newId, Position, {
|
||||
...pos,
|
||||
pos: [pos.pos[0] + this.offset[0], pos.pos[1] + this.offset[1]]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executor.run(paste) // one transaction, one undo step
|
||||
```
|
||||
|
||||
## Addressing the Six Concerns
|
||||
|
||||
The PR review raised six "critical conflicts." Here is how the command layer
|
||||
resolves each:
|
||||
|
||||
### 1. "The World API is imperative, not command-based"
|
||||
|
||||
Correct — by design. The World is the store. Commands are the public
|
||||
mutation API above it. `world.setComponent()` is to commands what
|
||||
`state[key] = value` is to Redux reducers.
|
||||
|
||||
### 2. "Systems are orchestrators, not command producers"
|
||||
|
||||
Systems are command **handlers**. A command's `execute()` calls system
|
||||
functions. Systems do not spontaneously mutate the World — they are invoked
|
||||
by commands.
|
||||
|
||||
### 3. "Auto-incrementing IDs are non-stable in concurrent environments"
|
||||
|
||||
For local-only operations, auto-increment is fine. For CRDT sync, entity
|
||||
creation goes through a CRDT-aware ID generator (Y.js provides this via
|
||||
`doc.clientID` + logical clock). The command layer can select the ID
|
||||
strategy:
|
||||
|
||||
```ts
|
||||
// Local-only command
|
||||
world.createEntity(kind) // auto-increment
|
||||
|
||||
// CRDT-aware command (future)
|
||||
world.createEntityWithId(kind, crdtGeneratedId)
|
||||
```
|
||||
|
||||
This is an ID generation concern, not an ECS architecture concern.
|
||||
|
||||
### 4. "No transaction primitive exists"
|
||||
|
||||
`world.transaction(label, fn)` is the primitive. It maps to one
|
||||
`beforeChange`/`afterChange` bracket. The command executor wraps every
|
||||
`execute()` call in a transaction. See the [migration plan's Phase 3→4
|
||||
gate](ecs-migration-plan.md#phase-3---4-gate-required) for the acceptance
|
||||
criteria.
|
||||
|
||||
### 5. "No idempotency guarantees"
|
||||
|
||||
Idempotency is a property of the command, not the store. Two strategies:
|
||||
|
||||
- **Content-addressed IDs**: The command specifies the entity ID rather than
|
||||
auto-generating. Replaying the command with the same ID is a no-op if the
|
||||
entity already exists.
|
||||
- **Command deduplication**: The command log tracks applied command IDs.
|
||||
Replaying an already-applied command is skipped.
|
||||
|
||||
Both are standard CRDT patterns and belong in the command executor, not the
|
||||
World.
|
||||
|
||||
### 6. "No error semantics"
|
||||
|
||||
Commands return results. The executor can wrap execution:
|
||||
|
||||
```ts
|
||||
type CommandResult<T> =
|
||||
| { status: 'applied'; value: T }
|
||||
| { status: 'rejected'; reason: string }
|
||||
| { status: 'no-op' }
|
||||
|
||||
function run<T>(command: Command<T>): CommandResult<T> {
|
||||
try {
|
||||
const value = world.transaction(command.type, () => command.execute(world))
|
||||
return { status: 'applied', value }
|
||||
} catch (e) {
|
||||
if (e instanceof RejectionError) {
|
||||
return { status: 'rejected', reason: e.message }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rejection semantics (e.g., `onConnectInput` returning false) throw a
|
||||
`RejectionError` inside the system, which the transaction rolls back.
|
||||
|
||||
## Why Two ADRs
|
||||
|
||||
ADR 0003 defines the command pattern and CRDT sync layer.
|
||||
ADR 0008 defines the entity data model.
|
||||
|
||||
They are **complementary architectural layers**, not competing proposals:
|
||||
|
||||
| Concern | Owns It |
|
||||
| ------------------------- | -------- |
|
||||
| Entity taxonomy and IDs | ADR 0008 |
|
||||
| Component decomposition | ADR 0008 |
|
||||
| World (store) | ADR 0008 |
|
||||
| Command interface | ADR 0003 |
|
||||
| Undo/redo via command log | ADR 0003 |
|
||||
| CRDT sync | ADR 0003 |
|
||||
| Serialization format | ADR 0008 |
|
||||
| Replay and idempotency | ADR 0003 |
|
||||
|
||||
Merging them into a single mega-ADR would conflate the data model with the
|
||||
mutation strategy. Keeping them separate allows each to evolve independently
|
||||
— the World can change its internal representation without affecting the
|
||||
command API, and the command layer can adopt new sync strategies without
|
||||
restructuring the entity model.
|
||||
|
||||
## Relationship to Lifecycle Scenarios
|
||||
|
||||
The [lifecycle scenarios](ecs-lifecycle-scenarios.md) show system-level
|
||||
calls (`ConnectivitySystem.connect()`, `ClipboardSystem.paste()`, etc.).
|
||||
These are the **inside** of a command — what the command handler does when
|
||||
the command is executed.
|
||||
|
||||
The scenarios deliberately omit the command layer to focus on how systems
|
||||
interact with the World. Adding command wrappers is mechanical: every
|
||||
system call shown in the scenarios becomes the body of a command's
|
||||
`execute()` method.
|
||||
|
||||
## When This Gets Built
|
||||
|
||||
The command layer is not part of the initial ECS migration phases (0–3).
|
||||
During Phases 0–3, the bridge layer provides mutation entry points that
|
||||
will later become command handlers. The command layer is introduced in
|
||||
Phase 4 when write paths migrate from legacy to ECS:
|
||||
|
||||
- **Phase 4a**: Position write commands replace direct `node.pos =` assignment
|
||||
- **Phase 4b**: Connectivity commands replace `node.connect()` /
|
||||
`node.disconnect()`
|
||||
- **Phase 4c**: Widget value commands replace direct store writes
|
||||
|
||||
Each Phase 4 step introduces commands for one concern, with the system
|
||||
function as the handler and the World transaction as the atomicity
|
||||
boundary.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Entity Interactions (Current System)
|
||||
|
||||
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md), whose target is realized as a set of dedicated Pinia stores (see [Proto-ECS Stores](proto-ecs-stores.md)).
|
||||
|
||||
## Entities
|
||||
|
||||
@@ -361,7 +361,7 @@ graph TD
|
||||
subgraph Stores
|
||||
WVS["WidgetValueStore
|
||||
(Pinia)"]
|
||||
PS["PromotionStore
|
||||
PES["PreviewExposureStore
|
||||
(Pinia)"]
|
||||
LM["LayoutMutations
|
||||
(composable)"]
|
||||
@@ -379,9 +379,9 @@ lastRerouteId, lastGroupId)"]
|
||||
Widget <-->|"value, label, disabled"| WVS
|
||||
WVS -.->|"keyed by graphId:nodeId:name"| Widget
|
||||
|
||||
%% PromotionStore
|
||||
SGNode -->|"tracks promoted widgets"| PS
|
||||
Widget -.->|"isPromotedByAny() query"| PS
|
||||
%% PreviewExposureStore
|
||||
SGNode -->|"host-scoped preview exposures"| PES
|
||||
PES -.->|"keyed by host node locator"| SGNode
|
||||
|
||||
%% LayoutMutations
|
||||
Node -->|"pos/size setter"| LM
|
||||
|
||||
@@ -115,7 +115,7 @@ If slots are reordered (e.g., by an extension adding a slot), all links referenc
|
||||
|
||||
### No Cross-Kind ID Safety
|
||||
|
||||
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. This is the core motivation for the branded ID types proposed in ADR 0008.
|
||||
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. The dedicated-store direction addresses this with branded string keys where cross-kind safety pays off (for example `WidgetId` in `widgetValueStore`, `src/types/widgetId.ts`).
|
||||
|
||||
## 5. Law of Demeter Violations
|
||||
|
||||
@@ -201,12 +201,12 @@ This means:
|
||||
|
||||
## How ECS Addresses These Problems
|
||||
|
||||
| Problem | ECS Solution |
|
||||
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||
| God objects | Data split into small, focused components; behavior lives in systems |
|
||||
| Circular dependencies | Entities are just IDs; components have no inheritance hierarchy |
|
||||
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
|
||||
| Inconsistent IDs | Branded per-kind IDs with compile-time safety |
|
||||
| Demeter violations | Systems query the World directly; no entity-to-entity references |
|
||||
| Scattered side effects | Version tracking becomes a system responsibility; stores become systems |
|
||||
| Render-time mutations | Render system reads components without writing; layout system runs separately |
|
||||
| Problem | ECS Solution |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| God objects | Data split into small, focused components in dedicated stores; behavior lives in systems |
|
||||
| Circular dependencies | Entities are addressed by string keys; components have no inheritance hierarchy |
|
||||
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
|
||||
| Inconsistent IDs | Branded string keys per store (for example `WidgetId`) for cross-kind safety |
|
||||
| Demeter violations | Systems query the relevant store directly; no entity-to-entity references |
|
||||
| Scattered side effects | Version tracking becomes a system responsibility; mutations flow through store command APIs |
|
||||
| Render-time mutations | Render system reads components without writing; layout system runs separately |
|
||||
|
||||
@@ -6,17 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Five stores extract entity state out of class instances into centralized,
|
||||
queryable registries. Promoted value-widget topology is no longer a store; ADR
|
||||
0009 represents it as ordinary linked `SubgraphInput` state.
|
||||
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 → nodeId:name` | `"${nodeId}:${widgetName}"` | 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 |
|
||||
| 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
|
||||
@@ -31,9 +34,9 @@ The closest thing to a true ECS component store in the codebase today.
|
||||
### State Shape
|
||||
|
||||
```
|
||||
Map<UUID, Map<WidgetKey, WidgetState>>
|
||||
Map<UUID, Map<WidgetId, WidgetState>>
|
||||
│ │ │
|
||||
graphId "nodeId:name" pure data object
|
||||
graphId "graphId:nodeId:name" pure data object
|
||||
```
|
||||
|
||||
`WidgetState` is a plain data object with no methods:
|
||||
@@ -56,7 +59,7 @@ Map<UUID, Map<WidgetKey, WidgetState>>
|
||||
**Phase 2 — `setNodeId()`:** Widget replaces its `_state` with a reference to the store's object:
|
||||
|
||||
```
|
||||
widget._state = useWidgetValueStore().registerWidget(graphId, { ...this._state, nodeId })
|
||||
widget._state = useWidgetValueStore().registerWidget(widgetId, { ...this._state, nodeId })
|
||||
```
|
||||
|
||||
After registration, the widget's getters/setters (`value`, `label`, `disabled`) are pass-throughs to the store. Mutations to the widget automatically sync to the store via shared object reference.
|
||||
@@ -119,20 +122,22 @@ Legacy `properties.proxyWidgets` is load-time migration input only.
|
||||
╰────────────────╯ ╰──────────────────────╯
|
||||
```
|
||||
|
||||
`PromotedWidgetViewManager`
|
||||
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
|
||||
synthetic widget views derived from linked subgraph inputs. It does not sit on
|
||||
top of a promotion registry.
|
||||
A promoted host widget is ordinary `WidgetState` in `WidgetValueStore`, keyed by
|
||||
the `WidgetId` carried on the `SubgraphInput` (`input.widgetId`). `SubgraphNode.widgets`
|
||||
is a read-only projection over the node's inputs that resolves each value via
|
||||
`useWidgetValueStore().getWidget(input.widgetId)`. There is no synthetic widget
|
||||
view object and no view cache to reconcile (PR 12617 deleted `PromotedWidgetView`
|
||||
and `PromotedWidgetViewManager`).
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ----------------------------- | --------- | ------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
|
||||
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | -------- | -------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| Promoted value is plain data | Yes | Host widget is `WidgetState` in the store, keyed by `WidgetId` |
|
||||
| Projection over data | Yes | `SubgraphNode.widgets` derives from inputs; no view cache |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
@@ -171,14 +176,14 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | --------- | --------------------------------------------------- |
|
||||
| Position data extracted | Yes | Closest to the ECS `Position` component |
|
||||
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
|
||||
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
|
||||
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
|
||||
| Module-scope access | **No** | Domain objects import store at module level |
|
||||
| No entity ID branding | **No** | Plain numbers, no type safety across kinds |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | --------- | ------------------------------------------------------- |
|
||||
| Position data extracted | Yes | Closest to the ECS `Position` component |
|
||||
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
|
||||
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
|
||||
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
|
||||
| Module-scope access | **No** | Domain objects import store at module level |
|
||||
| Per-store keying | Yes | Owns `nodeId`/`linkId`/`rerouteId` keys for its concern |
|
||||
|
||||
## 5. Pattern Analysis
|
||||
|
||||
@@ -190,30 +195,32 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
### Target Design and Remaining Gaps
|
||||
|
||||
Dedicated per-domain stores with their own string keys are the target, not a way
|
||||
station toward one unified registry. The remaining gaps are about behavior and
|
||||
data flow, not about collapsing the stores together.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Have["What We Have"]
|
||||
subgraph Have["What We Have (and Want)"]
|
||||
style Have fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
H1["Centralized data stores"]
|
||||
H1["Dedicated per-domain stores"]
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, preview exposures)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
H5["Per-store string keys
|
||||
(WidgetId, nodeLocatorId)"]
|
||||
H6["Position extraction
|
||||
(LayoutStore)"]
|
||||
end
|
||||
|
||||
subgraph Missing["What's Missing"]
|
||||
style Missing fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
M1["Unified World
|
||||
(6 stores, 6 keying strategies)"]
|
||||
M2["Branded entity IDs
|
||||
(keys are string concatenations)"]
|
||||
M3["System layer
|
||||
(mutations from anywhere)"]
|
||||
M3["System / command layer
|
||||
(sanctioned mutation path)"]
|
||||
M4["Complete extraction
|
||||
(behavior still on classes)"]
|
||||
M5["No entity-to-entity refs
|
||||
@@ -225,19 +232,21 @@ graph TD
|
||||
|
||||
### Keying Strategy Comparison
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
Each store owns the identity scheme that fits its concern:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Key Type | Type-Safe? |
|
||||
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
|
||||
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
identity (`host node locator + SubgraphInput.name`) instead of interior source
|
||||
identity.
|
||||
`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
|
||||
stores can adopt their own branded string keys where cross-kind safety pays off,
|
||||
without a shared entity-ID space. For promoted value widgets, ADR 0009 keys on
|
||||
the host boundary: the input's `WidgetId` (host node locator + `SubgraphInput.name`),
|
||||
not interior source identity.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ graph TD
|
||||
|
||||
subgraph Unified["Unified: Composition"]
|
||||
direction TB
|
||||
W["World (flat)"]
|
||||
W["Dedicated stores (flat)"]
|
||||
N1["Node A
|
||||
graphScope: root"]
|
||||
N2["Node B (subgraph carrier)
|
||||
@@ -95,30 +95,29 @@ graph TD
|
||||
style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
In the ECS World:
|
||||
Across the dedicated stores:
|
||||
|
||||
- **Every graph is a graph.** The "root" graph is simply the one whose
|
||||
`graphScope` has no parent.
|
||||
- **Nesting is a component, not a type.** A node can carry a
|
||||
`SubgraphStructure` component, which references another graph scope. That
|
||||
scope contains its own entities — nodes, links, widgets, reroutes, groups —
|
||||
all living in the same flat World.
|
||||
- **One World per workflow.** All entities across all nesting levels coexist in
|
||||
a single World, each tagged with a `graphScope` identifier. There are no
|
||||
sub-worlds, no recursive containers. The fractal structure is encoded in the
|
||||
data, not in the container hierarchy.
|
||||
- **Entity taxonomy: six kinds, not seven.** ADR 0008 defines seven entity kinds
|
||||
including `SubgraphEntityId`. Under unification, "subgraph" is not an entity
|
||||
kind — it is a node with a component. The taxonomy becomes: Node, Link,
|
||||
Widget, Slot, Reroute, Group.
|
||||
- **ID counters remain global.** All entity IDs are allocated from a single
|
||||
counter space, shared across all nesting levels. This preserves the current
|
||||
`rootGraph.state` behavior and guarantees ID uniqueness across the entire
|
||||
World.
|
||||
- **Graph scope parentage is tracked.** The World maintains a scope registry:
|
||||
each `graphId` maps to its parent `graphId` (or null for the root). This
|
||||
enables the ancestor walk required by the acyclicity invariant and supports
|
||||
queries like "all entities transitively contained by this graph."
|
||||
- **Nesting is a component.** A node can carry a `SubgraphStructure` component,
|
||||
which references another graph scope. That scope contains its own entities —
|
||||
nodes, links, widgets, reroutes, groups — and each store entry tags the scope
|
||||
it belongs to.
|
||||
- **Scope tagging over container nesting.** Entries across all nesting levels
|
||||
live in the same stores, each tagged with a `graphScope` identifier. The
|
||||
stores stay flat; the fractal structure is encoded in the keys and scope tags,
|
||||
with no recursive containers.
|
||||
- **Entity taxonomy: six kinds.** A subgraph is a node carrying a
|
||||
`SubgraphStructure` component. The taxonomy is: Node, Link, Widget, Slot,
|
||||
Reroute, Group. `SubgraphEntityId` is replaced by a `GraphId` scope identifier.
|
||||
- **Numeric ID counters stay shared.** Node/link/reroute IDs are allocated from
|
||||
a single counter space across nesting levels, preserving the current
|
||||
`rootGraph.state` behavior and guaranteeing uniqueness. Widget keys embed
|
||||
their scope directly (`WidgetId = graphId:nodeId:name`).
|
||||
- **Graph scope parentage is tracked.** A scope registry maps each `graphId` to
|
||||
its parent `graphId` (or null for the root). This enables the ancestor walk
|
||||
required by the acyclicity invariant and supports queries like "all entities
|
||||
transitively contained by this graph."
|
||||
|
||||
### The acyclicity invariant
|
||||
|
||||
@@ -367,15 +366,17 @@ sequenceDiagram
|
||||
Exec->>IW: reads input value (42)
|
||||
```
|
||||
|
||||
### Candidate B: Simplified component promotion
|
||||
### Candidate B: Simplified component promotion (rejected)
|
||||
|
||||
ADR 0009 chose Candidate A. Candidate B is retained here as the rejected
|
||||
alternative; it relied on a source-widget lookup model that no longer exists.
|
||||
|
||||
Promotion remains a first-class concept, simplified from three layers to one:
|
||||
|
||||
- A `WidgetPromotion` component on a widget entity:
|
||||
`{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId }`
|
||||
- The SubgraphNode's widget list includes promoted widget entity IDs directly
|
||||
- Value reads/writes delegate to the source widget's `WidgetValue` component via
|
||||
World lookup
|
||||
- A `WidgetPromotion` component on a widget entity referencing the host node and
|
||||
source widget
|
||||
- The SubgraphNode's widget list includes promoted widget references directly
|
||||
- Value reads/writes delegate to the source widget's value via a store lookup
|
||||
- Serialized as `properties.proxyWidgets` (unchanged)
|
||||
|
||||
This removes the ViewManager and proxy widget reconciliation but preserves the
|
||||
@@ -399,8 +400,9 @@ concept of promotion as distinct from connection.
|
||||
|
||||
Whichever candidate is chosen:
|
||||
|
||||
- **`WidgetEntityId` is internal.** Serialization uses widget name + parent node
|
||||
reference. This is settled (see Section 4).
|
||||
- **Internal identity is the `WidgetId` string.** Serialization uses widget name
|
||||
- parent node reference, while runtime state keys on `WidgetId`
|
||||
(`graphId:nodeId:name`). This is settled (see Section 4).
|
||||
- **The type → widget mapping is authoritative.** The widget registry
|
||||
(`widgetStore.widgets`) is the single source of truth for which types produce
|
||||
widgets. No parallel mechanism should duplicate this.
|
||||
@@ -473,10 +475,10 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------ |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | -------------------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to store-backed state on load |
|
||||
|
||||
The migration pattern: load any supported format and normalize to the internal
|
||||
model. The system accepts old formats indefinitely but produces the current
|
||||
@@ -486,7 +488,7 @@ format on save.
|
||||
|
||||
| Context | Identity | Example |
|
||||
| -------------------- | ---------------------------------------------------------- | ---------------------------------- |
|
||||
| **Internal (World)** | `WidgetEntityId` (opaque branded number) | `42 as WidgetEntityId` |
|
||||
| **Internal (store)** | `WidgetId` composite string | `'graphId:42:seed' as WidgetId` |
|
||||
| **Serialized** | Position in `widgets_values[]` + name from node definition | `widgets_values[2]` → third widget |
|
||||
|
||||
On save: the `SerializationSystem` queries `WidgetIdentity.name` and
|
||||
@@ -494,7 +496,7 @@ On save: the `SerializationSystem` queries `WidgetIdentity.name` and
|
||||
order.
|
||||
|
||||
On load: widget values are matched by name against the node definition's input
|
||||
specs, then assigned `WidgetEntityId`s from the global counter.
|
||||
specs, then registered in `WidgetValueStore` under their `WidgetId`.
|
||||
|
||||
This is the existing contract, preserved exactly.
|
||||
|
||||
@@ -553,7 +555,7 @@ This document proposes or surfaces the following changes to
|
||||
| Entity taxonomy | 7 kinds including `SubgraphEntityId` | 6 kinds — subgraph is a node with `SubgraphStructure` component |
|
||||
| `SubgraphEntityId` | `string & { __brand: 'SubgraphEntityId' }` | Eliminated; replaced by `GraphId` scope identifier |
|
||||
| Subgraph components | `SubgraphStructure`, `SubgraphMeta` listed as separate-entity components | Become node components on SubgraphNode entities |
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Storage structure | Implied per-graph containment | Dedicated stores with `graphScope`-tagged entries; no single registry |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
|
||||
|
||||
@@ -16,6 +16,7 @@ Extensions are the primary way to add functionality to ComfyUI. They can be cust
|
||||
- Extension architecture principles
|
||||
- Hook execution sequence
|
||||
- Best practices for extension development
|
||||
- **[Node ID Migration Notes](./node-id-migration.md)** - Compatibility guidance for branded node IDs and subgraph boundary sentinel values
|
||||
|
||||
## Quick Links
|
||||
|
||||
|
||||
15
docs/extensions/node-id-migration.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Node ID Migration Notes
|
||||
|
||||
ComfyUI frontend now normalizes local node IDs to the branded `NodeId` string
|
||||
type at internal boundaries. Serialized workflows and API payloads may still
|
||||
contain numeric IDs, but litegraph node and link fields should be treated as
|
||||
strings after they enter the frontend.
|
||||
|
||||
Extension authors should avoid numeric comparisons against node IDs. In
|
||||
particular, subgraph boundary sentinels are exposed as branded string IDs:
|
||||
|
||||
- `SUBGRAPH_INPUT_ID` serializes from `-10`
|
||||
- `SUBGRAPH_OUTPUT_ID` serializes from `-20`
|
||||
|
||||
Use the exported constants where available, or normalize both sides to strings
|
||||
before comparing legacy values.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.5",
|
||||
"version": "1.47.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,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}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@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));
|
||||
|
||||
|
||||
3
packages/design-system/src/icons/3d-decomp.svg
Normal file
@@ -0,0 +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="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1014 B |
3
packages/design-system/src/icons/brightness-contrast.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.65 6.36397V5.71397H11.35V6.36397H12H12.65ZM11.35 17.6777C11.35 18.0367 11.641 18.3277 12 18.3277C12.359 18.3277 12.65 18.0367 12.65 17.6777H12H11.35ZM17.6569 14.0049L18.2695 14.222L17.6569 14.0049ZM17.6586 10L18.2714 9.78339L17.6586 10ZM16.6114 15.8388L17.1107 16.255L16.6114 15.8388ZM12.65 20.5C12.65 20.141 12.359 19.85 12 19.85C11.641 19.85 11.35 20.141 11.35 20.5H12H12.65ZM11.35 22C11.35 22.359 11.641 22.65 12 22.65C12.359 22.65 12.65 22.359 12.65 22H12H11.35ZM12.65 2C12.65 1.64101 12.359 1.35 12 1.35C11.641 1.35 11.35 1.64101 11.35 2H12H12.65ZM11.35 3.5C11.35 3.85899 11.641 4.15 12 4.15C12.359 4.15 12.65 3.85899 12.65 3.5H12H11.35ZM20.5 11.35C20.141 11.35 19.85 11.641 19.85 12C19.85 12.359 20.141 12.65 20.5 12.65V12V11.35ZM22 12.65C22.359 12.65 22.65 12.359 22.65 12C22.65 11.641 22.359 11.35 22 11.35V12V12.65ZM2 11.35C1.64101 11.35 1.35 11.641 1.35 12C1.35 12.359 1.64101 12.65 2 12.65V12V11.35ZM3.5 12.65C3.85899 12.65 4.15 12.359 4.15 12C4.15 11.641 3.85899 11.35 3.5 11.35V12V12.65ZM18.47 17.5508C18.2162 17.2969 17.8046 17.2969 17.5508 17.5508C17.2969 17.8046 17.2969 18.2162 17.5508 18.47L18.0104 18.0104L18.47 17.5508ZM18.6114 19.5307C18.8653 19.7845 19.2768 19.7845 19.5307 19.5307C19.7845 19.2768 19.7845 18.8653 19.5307 18.6114L19.0711 19.0711L18.6114 19.5307ZM5.38855 4.46931C5.13471 4.21547 4.72315 4.21547 4.46931 4.46931C4.21547 4.72315 4.21547 5.13471 4.46931 5.38855L4.92893 4.92893L5.38855 4.46931ZM5.52997 6.44921C5.78381 6.70305 6.19537 6.70305 6.44921 6.44921C6.70305 6.19537 6.70305 5.78381 6.44921 5.52997L5.98959 5.98959L5.52997 6.44921ZM6.44921 18.47C6.70305 18.2162 6.70305 17.8046 6.44921 17.5508C6.19537 17.2969 5.78382 17.2969 5.52997 17.5508L5.98959 18.0104L6.44921 18.47ZM4.46931 18.6114C4.21547 18.8653 4.21547 19.2768 4.46931 19.5307C4.72315 19.7845 5.13471 19.7845 5.38855 19.5307L4.92893 19.0711L4.46931 18.6114ZM19.5307 5.38855C19.7845 5.13471 19.7845 4.72315 19.5307 4.46931C19.2768 4.21547 18.8653 4.21547 18.6114 4.46931L19.0711 4.92893L19.5307 5.38855ZM17.5508 5.52997C17.2969 5.78381 17.2969 6.19537 17.5508 6.44921C17.8046 6.70305 18.2162 6.70305 18.47 6.44921L18.0104 5.98959L17.5508 5.52997ZM12 18V17.35C9.04528 17.35 6.65 14.9547 6.65 12H6H5.35C5.35 15.6727 8.32731 18.65 12 18.65V18ZM6 12H6.65C6.65 9.04528 9.04528 6.65 12 6.65V6V5.35C8.32731 5.35 5.35 8.32731 5.35 12H6ZM18 12H17.35C17.35 12.6281 17.242 13.2296 17.0442 13.7878L17.6569 14.0049L18.2695 14.222C18.5161 13.5264 18.65 12.7781 18.65 12H18ZM12 14L11.9994 14.65L17.6563 14.6549L17.6569 14.0049L17.6574 13.3549L12.0006 13.35L12 14ZM12 12H11.35V14H12H12.65V12H12ZM12 12V12.65H18V12V11.35H12V12ZM12 10H11.35V12H12H12.65V10H12ZM17.6586 10L17.0457 10.2166C17.2426 10.7736 17.35 11.3735 17.35 12H18H18.65C18.65 11.2239 18.5168 10.4776 18.2714 9.78339L17.6586 10ZM12 10V10.65H17.6586V10V9.35H12V10ZM12 14H11.35V15.8388H12H12.65V14H12ZM12 15.8388H11.35V17.6777H12H12.65V15.8388H12ZM17.6569 14.0049L17.0442 13.7878C16.8311 14.3891 16.5132 14.9414 16.1121 15.4227L16.6114 15.8388L17.1107 16.255C17.6087 15.6575 18.0041 14.9708 18.2695 14.222L17.6569 14.0049ZM16.6114 15.8388L16.1121 15.4227C15.1297 16.6015 13.6525 17.35 12 17.35V18V18.65C14.0546 18.65 15.8919 17.7175 17.1107 16.255L16.6114 15.8388ZM12 15.8388V16.4888H16.6114V15.8388V15.1888H12V15.8388ZM12 6.36397H11.35V8.18199H12H12.65V6.36397H12ZM12 8.18199H11.35V10H12H12.65V8.18199H12ZM12 6V6.65C13.6612 6.65 15.1452 7.40634 16.1275 8.59587L16.6287 8.18199L17.1299 7.7681C15.9112 6.29234 14.0654 5.35 12 5.35V6ZM16.6287 8.18199L16.1275 8.59587C16.5223 9.07398 16.8353 9.62135 17.0457 10.2166L17.6586 10L18.2714 9.78339C18.0094 9.0421 17.62 8.36161 17.1299 7.7681L16.6287 8.18199ZM12 8.18199V8.83199H16.6287V8.18199V7.53199H12V8.18199ZM12 20.5H11.35V22H12H12.65V20.5H12ZM12 2H11.35V3.5H12H12.65V2H12ZM20.5 12V12.65H22V12V11.35H20.5V12ZM2 12V12.65H3.5V12V11.35H2V12ZM18.0104 18.0104L17.5508 18.47L18.6114 19.5307L19.0711 19.0711L19.5307 18.6114L18.47 17.5508L18.0104 18.0104ZM4.92893 4.92893L4.46931 5.38855L5.52997 6.44921L5.98959 5.98959L6.44921 5.52997L5.38855 4.46931L4.92893 4.92893ZM5.98959 18.0104L5.52997 17.5508L4.46931 18.6114L4.92893 19.0711L5.38855 19.5307L6.44921 18.47L5.98959 18.0104ZM19.0711 4.92893L18.6114 4.46931L17.5508 5.52997L18.0104 5.98959L18.47 6.44921L19.5307 5.38855L19.0711 4.92893Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -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="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 18L7 20L5 22M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
5
packages/design-system/src/icons/channels.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 9C18 12.3137 15.3137 15 12 15C8.68629 15 6 12.3137 6 9C6 5.68629 8.68629 3 12 3C15.3137 3 18 5.68629 18 9Z" stroke="white" stroke-width="1.3"/>
|
||||
<path d="M14.5 15C14.5 18.3137 11.8137 21 8.5 21C5.18629 21 2.5 18.3137 2.5 15C2.5 11.6863 5.18629 9 8.5 9C11.8137 9 14.5 11.6863 14.5 15Z" stroke="white" stroke-width="1.3"/>
|
||||
<path d="M21.5 15C21.5 18.3137 18.8137 21 15.5 21C12.1863 21 9.5 18.3137 9.5 15C9.5 11.6863 12.1863 9 15.5 9C18.8137 9 21.5 11.6863 21.5 15Z" stroke="white" stroke-width="1.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6154 18.8127C10.0968 18.9352 9.55598 19 9 19C5.13401 19 2 15.866 2 12C2 8.13401 5.13401 5 9 5C9.55598 5 10.0968 5.06482 10.6154 5.18731M13.3846 18.8127C13.9032 18.9352 14.444 19 15 19C18.866 19 22 15.866 22 12C22 8.13401 18.866 5 15 5C14.444 5 13.9032 5.06482 13.3846 5.18731M19 12C19 15.866 15.866 19 12 19C8.13401 19 5 15.866 5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12Z" stroke="white" stroke-width="1.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
1
packages/design-system/src/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |