Compare commits

..

64 Commits

Author SHA1 Message Date
GitHub Action
7f0fd79a76 [automated] Apply ESLint and Oxfmt fixes 2026-04-15 04:51:24 +00:00
Alexander Brown
bdced9ce86 fix: add architecture-adventure workspace to knip config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:03:09 -07:00
Alexander Brown
5d83ee5f3c feat(adventure): scaffold Vite project for Codebase Caverns v2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:58:54 -07:00
Alexander Brown
cd7c28ed92 Add .superpowers to gitignore 2026-03-26 16:55:16 -07:00
Alexander Brown
0b425dfd80 docs: clarify ECS instance scoping, subgraph recursion, layout decoupling, and phase gates
Amp-Thread-ID: https://ampcode.com/threads/T-019d2b99-d068-77aa-be88-5846f1e74e54
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 16:55:15 -07:00
Alexander Brown
0a880faad8 docs: add Command Forge room and extension migration to adventure game
Convert the Side Panel lore room into "The Command Forge" with a new
challenge teaching the Command Pattern / World API layering from the
ecs-world-command-api.md addendum. Enrich the Services room with
extension migration patterns from the updated migration plan.

- 8 challenges -> 9 (new: The Mutation Gateway)
- 13 artifacts -> 16 (CommandExecutor, Command Interface, Extension Migration Guide)
- Generate pixel art icons for new artifacts and choice buttons
- Update room image to command forge theme
- Recalculate speedrun route and spaghetti singularity path
- Update walkthrough with new challenge solutions and stats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:55:08 -07:00
Alexander Brown
b8f26177c9 fix: address remaining ECS ADR review docs feedback
Amp-Thread-ID: https://ampcode.com/threads/T-019d275e-a5aa-7057-8699-f403397f0df2
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 16:55:08 -07:00
Alexander Brown
244e549116 docs: add World command API addendum and cross-reference from ADR 0008
- Add ecs-world-command-api.md showing how imperative World calls translate
  to serializable commands executed by systems
- Add 'Relationship to ADR 0003' section to ADR 0008 clarifying the
  complementary layering: Commands (intent) → Systems (handlers) → World (store)

Amp-Thread-ID: https://ampcode.com/threads/T-019d270c-1975-7590-aaae-551eb71b26ff
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 16:55:07 -07:00
Alexander Brown
d7f425b51b feat: add 5 missing adventure game icons
Generate subgraph challenge choice icons (a/b/c) and artifact icons
(subgraph-structure, typed-contracts) to complete the icon set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:55:06 -07:00
Alexander Brown
1a9091605a More friendly styles, use the whole screen. 2026-03-26 16:55:05 -07:00
DrJKL
805ff2db31 feat: add Subgraph Depths room, improve accessibility and mobile layout
- Add "The Subgraph Depths" room with Widget Promotion Decision
  challenge reflecting the new subgraph boundaries and graph
  unification design from ADR0008
- Update ECS room description to reference 6 entity kinds, flat
  World with graphScope tags, and subgraph-as-component model
- Update Component Gallery challenge to reference graph unification
- Fix WCAG contrast: --muted #8b949e -> #9ea7b0 (AAA on surface),
  .dec-slot.empty text from invisible --border to --muted
- Fix mobile HUD: compact layout with single-letter stat labels,
  reduced padding, smaller buttons
- Fix mobile sidebar: full-width via align-self:stretch, was
  constrained by align-self:flex-start from desktop sticky mode
- Fix map card structure: challenge badge in flex row header for
  consistent alignment across cards with/without challenges
- Update walkthrough: 10 rooms, 8 challenges, 13 artifacts,
  new optimal route, updated Spaghetti path and playthrough count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:55:04 -07:00
GitHub Action
ac5c291990 [automated] Apply ESLint and Oxfmt fixes 2026-03-26 16:54:58 -07:00
DrJKL
e2f0add917 docs: add subgraph boundaries doc, unify graph model across ECS architecture
- New companion doc: subgraph-boundaries-and-promotion.md
  - Graph model unification: all graphs are isomorphic, 7→6 entity kinds
  - Typed boundary contracts replace virtual nodes and magic IDs
  - Widget promotion as open decision (connections-only vs simplified)
  - Serialization boundary with indefinite backward-compatible loading
- Update ADR 0008: remove SubgraphEntityId, add GraphId scope, flat World
- Update ecs-target-architecture: World diagram, Entity IDs, problem map
- Update ecs-migration-plan: Phase 1a/1c types and World interface
- Update ecs-lifecycle-scenarios: pack/unpack use graphScope re-parenting
- Update proto-ecs-stores and entity-interactions: annotate subgraph rows

Amp-Thread-ID: https://ampcode.com/threads/T-019d2311-3707-746a-ae9c-65e6f0d67f3e
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 16:54:57 -07:00
DrJKL
dadc5a076f fix: strip leading whitespace from multi-line mermaid labels in architecture docs
Amp-Thread-ID: https://ampcode.com/threads/T-019d2303-d3a2-7659-9beb-24fc8b6813ee
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 16:54:50 -07:00
DrJKL
ddb6562ff6 fix: tighten ECS architecture docs and migration gates
Amp-Thread-ID: https://ampcode.com/threads/T-019d22e0-5d0a-767a-92c5-84ad5f2a4d00
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 16:54:39 -07:00
Alexander Brown
7294072f9e fix: replace blanket CSS reset with modern reset, fix text jiggle
- Modern CSS reset (Comeau/Bell style) replaces * { margin: 0; padding: 0 }
- Restore dialog margin: auto for proper centering
- Remove translateY from room transitions to prevent text jiggle
- Opacity-only fade for clean room changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:20 -07:00
Alexander Brown
3ee2cd37c8 fix: correct Spaghetti Singularity walkthrough path
The previous guide suggested picking all bad-rated answers, but
Litegraph A (Rewrite, Debt -20) and Services B (Big bang, Debt -10)
actually lower debt, making it impossible to reach Debt >= 70.

Replaced with a proven path using debt-adding choices (C/C/C/C/C/B/B)
that reaches Debt 95. Added warning about the counterintuitive trap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:19 -07:00
Alexander Brown
cf4bf78aa0 fix: align map arrows and normalize challenge boxes in walkthrough
Map: all pipe bodies now align with arrow heads at same column.
Challenge boxes: all lines normalized to 72 chars wide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:18 -07:00
Alexander Brown
08eee56a68 fix: normalize ending box widths to 72 chars in walkthrough
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:17 -07:00
Alexander Brown
c3617e0af8 fix: align ASCII art in walkthrough (map, ending boxes, building)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:16 -07:00
Alexander Brown
7c976e128e docs: add GameFAQs-style walkthrough with ASCII art and pro tips
Complete spoiler guide: all 7 challenge solutions, optimal speedrun
route, all 4 endings, achievement unlock strategies, artifact
checklist, and architecture reading order recommendations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:16 -07:00
Alexander Brown
886bc1ef7c refactor: convert map and ending overlays to native dialog elements
- Map uses <dialog> with showModal()/close(), click-outside-to-close
- Ending uses <dialog> with Escape blocked for real endings, allowed for previews
- CSS uses @starting-style for smooth open/close transitions
- Removes manual backdrop div and z-index management
- Native focus trapping and accessibility from <dialog>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:15 -07:00
Alexander Brown
1cfaaac511 fix: previewing an ending mid-game closes without resetting state
Button shows "Close" for previews, "Play Again" for real endings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:14 -07:00
Alexander Brown
a984872ae3 feat: click unlocked ending badges to review their ending screen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:13 -07:00
Alexander Brown
b78db34e42 feat: localStorage persistence, restart button, and ending achievements
- Game state persists across page refreshes via localStorage
- Restart button in HUD resets game without losing achievements
- Endings sidebar tracks which of the 4 endings have been unlocked
- Achievement icons generated for each ending (locked/unlocked states)
- Achievements persist across runs in separate storage key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:12 -07:00
Alexander Brown
6c89fa9385 fix: add missing room variable in renderRoom, improve room transitions
Split render into render() (animation) and renderRoom() (content swap).
Fade-out slides up, fade-in slides down for a smoother feel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:11 -07:00
Alexander Brown
1471684821 feat: add Open Graph and Twitter Card meta tags for rich previews
Uses entry room pixel art as preview image via raw.githubusercontent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:11 -07:00
Alexander Brown
94791b4e65 feat: make Log section collapsible with smooth animation
Uses native <details>/<summary> with interpolate-size for smooth
height transition. Chevron indicator rotates on open/close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:10 -07:00
Alexander Brown
fc8775bf38 feat: preload images for adjacent rooms to prevent flash on navigate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:09 -07:00
Alexander Brown
6e81d71f1b feat: add inline emoji favicon for adventure game
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:08 -07:00
Alexander Brown
f92205ca46 feat: challenge choice icons and decisions tracker in sidebar
- 20 choice icons generated (one per A/B/C option across 7 challenges)
- Challenge buttons now card-style columns with icon, key badge overlay, and text
- Decisions sidebar section shows icon grid of choices made so far
- Slots colored by rating (green/yellow/red), hover label shows details
- Unified icon generation script handles both artifact and choice prompts
- Choice icon prompt reference JSON

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:07 -07:00
Alexander Brown
96c737bc0e feat: graphical inventory with generated pixel art icons
- 11 artifact icons generated via Z-Image Turbo (128x128)
- Inventory displays as icon grid with hover label strip
- Artifacts in rooms show icon thumbnails alongside name/type
- Icon generation script with skip-existing logic
- Prompt reference JSON for all artifact icons
- Fix inventory not resetting on Play Again
- Add user-select: none to presentational elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:07 -07:00
Alexander Brown
301cdda9f0 feat: add educational callouts, scorecard, GitHub links, and fix layout
- Link code references to actual files on GitHub main branch
- Show recommended answer + architecture doc link after each challenge
- End-game scorecard recaps all 7 decisions vs. recommended approach
- Challenge progress counter (0/7) in HUD
- Map is now a centered overlay with backdrop (Escape to close)
- Page scrolls naturally; sidebar sticks; no more clipped content
- Subtle fade transition between rooms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:06 -07:00
Alexander Brown
91fd59f246 feat: add decision system, stats, generated images to architecture adventure
- 7 architectural challenge encounters with branching choices and stat effects
- 4 tracked stats: Technical Debt, Code Quality, Team Morale, ECS Migration
- 4 endings based on accumulated decisions (Spaghetti Singularity through ECS Enlightenment)
- 9 pixel art room images generated via Z-Image Turbo (1152x640, no LoRA)
- Image generation script (generate-images.py) and prompt reference JSON
- Keyboard navigation for challenges (A/B/C) and rooms (1/2/3)
- Map badges showing pending/completed challenge status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:05 -07:00
Claude
0b924e3ae1 feat: add architecture adventure game skeleton
Interactive HTML game for exploring the ComfyUI frontend architecture.
Includes 9 rooms covering entry point, components, stores, services,
litegraph engine, ECS, renderer, composables, and side panel layers.
Keyboard navigation, map view, inventory, and activity log.

https://claude.ai/code/session_01PivAjqNcLVzZVF3C5irFdW
2026-03-26 16:54:04 -07:00
Alexander Brown
b468ea83ea docs: draft ECS component interfaces and World type
Example interfaces for all 7 entity kinds (Node, Link, Slot, Widget,
Reroute, Group, Subgraph), branded entity ID types with cast helpers,
and a Map-backed World implementation. Reuses existing litegraph types
(Point, Size, INodeFlags, ISlotType, etc.) for migration compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:04 -07:00
Alexander Brown
5892720f69 docs: draft ECS component interfaces and World type
Example interfaces for all 7 entity kinds (Node, Link, Slot, Widget,
Reroute, Group, Subgraph), branded entity ID types with cast helpers,
and a Map-backed World implementation. Reuses existing litegraph types
(Point, Size, INodeFlags, ISlotType, etc.) for migration compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:54:03 -07:00
Dante
ff263fced0 fix: make splitter state key position-aware to prevent shared panel widths (#9525)
## Summary

Fix right-side sidebar panels and left-side panels sharing the same
PrimeVue Splitter state key, causing them to incorrectly apply each
other's saved widths.

## Changes

- **What**: Make `sidebarStateKey` position-aware by including
`sidebarLocation` and offside panel visibility in the localStorage key

## Problem

When sidebar location is set to **right**, all panels (both the
right-side sidebar like Job History and left-side panels like Workflow
overview) share a single PrimeVue Splitter `state-key`
(`unified-sidebar`). PrimeVue persists panel widths to localStorage
using this key, so any resize on one side gets applied to the other.

### AS-IS (before fix)

The `sidebarStateKey` is computed without any awareness of panel
position:

```typescript
// Always returns 'unified-sidebar' (when unified width enabled)
// or the active tab id — regardless of sidebar location or offside panel state
const sidebarStateKey = computed(() => {
  return unifiedWidth.value
    ? 'unified-sidebar'
    : (activeSidebarTabId.value ?? 'default-sidebar')
})
```

This produces a **single localStorage key** for all layout
configurations. The result:

1. Set sidebar to **right**, open **Job History** → resize it smaller →
saved to `unified-sidebar`
2. Open **Workflow overview** (appears on the left as an offside panel)
→ loads the same `unified-sidebar` key → gets the Job History width
applied to a completely different panel position
3. Both panels open simultaneously share the same persisted width, even
though they are on opposite sides of the screen

This is exactly the behavior shown in the [issue
screenshots](https://github.com/Comfy-Org/ComfyUI_frontend/issues/9440):
pulling the Workflow overview smaller also changes Job History to that
same size, and vice versa.

### TO-BE (after fix)

The `sidebarStateKey` now includes `sidebarLocation` (`left`/`right`)
and whether the offside panel is visible:

```typescript
const sidebarTabKey = computed(() => {
  return unifiedWidth.value
    ? 'unified-sidebar'
    : (activeSidebarTabId.value ?? 'default-sidebar')
})

const sidebarStateKey = computed(() => {
  const base = sidebarTabKey.value
  const suffix = showOffsideSplitter.value ? '-with-offside' : ''
  return `${base}-${sidebarLocation.value}${suffix}`
})
```

This produces **distinct localStorage keys** per layout configuration:
| Layout | Key |
|--------|-----|
| Sidebar left, no offside | `unified-sidebar-left` |
| Sidebar left, right panel open | `unified-sidebar-left-with-offside` |
| Sidebar right, no offside | `unified-sidebar-right` |
| Sidebar right, left panel open | `unified-sidebar-right-with-offside`
|

Each configuration now persists and restores its own panel sizes
independently, so resizing Job History on the right no longer affects
Workflow overview on the left.

## Review Focus

- The offside suffix (`-with-offside`) is necessary because the Splitter
transitions from a 2-panel layout (sidebar + center) to a 3-panel layout
(sidebar + center + offside) — these are fundamentally different panel
configurations and should not share persisted sizes.

Fixes #9440

## Screenshots (if applicable)

See issue for reproduction screenshots:
https://github.com/Comfy-Org/ComfyUI_frontend/issues/9440

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 08:31:56 +09:00
Alexander Brown
3e197b5c57 docs: ADR 0008 — Entity Component System (#10420)
## Summary

Architecture documentation proposing an Entity Component System for the
litegraph layer.

```mermaid
graph LR
    subgraph Today["Today: Spaghetti"]
        God["🍝 God Objects"]
        Circ["🔄 Circular Deps"]
        Mut["💥 Render Mutations"]
    end

    subgraph Tomorrow["Tomorrow: ECS"]
        ID["🏷️ Branded IDs"]
        Comp["📦 Components"]
        Sys["⚙️ Systems"]
        World["🌍 World"]
    end

    God -->|"decompose"| Comp
    Circ -->|"flatten"| ID
    Mut -->|"separate"| Sys
    Comp --> World
    ID --> World
    Sys -->|"query"| World
```

## Changes

- **What**: ADR 0008 + 4 architecture docs (no code changes)
- `docs/adr/0008-entity-component-system.md` — entity taxonomy, branded
IDs, component decomposition, migration strategy
- `docs/architecture/entity-interactions.md` — as-is Mermaid diagrams of
all entity relationships
- `docs/architecture/entity-problems.md` — structural problems with
file:line evidence
- `docs/architecture/ecs-target-architecture.md` — target architecture
diagrams
- `docs/architecture/proto-ecs-stores.md` — analysis of existing Pinia
stores as proto-ECS patterns

## Review Focus

- Does the entity taxonomy (Node, Link, Subgraph, Widget, Slot, Reroute,
Group) cover all cases?
- Are the component decompositions reasonable starting points?
- Is the migration strategy (bridge layer, incremental extraction)
feasible?
- Are there entity interactions or problems we missed?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10420-docs-ADR-0008-Entity-Component-System-32d6d73d365081feb048d16a5231d350)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-26 16:14:44 -07:00
Deep Mehta
9573074ea6 feat: add model-to-node mappings for 10 node packs (#10560)
## Summary
Add `MODEL_NODE_MAPPINGS` entries for 10 model directories missing UI
backlinks.

### New mappings
- `BiRefNet` → `LayerMask: LoadBiRefNetModelV2` (`version`)
- `EVF-SAM` → `LayerMask: EVFSAMUltra` (`model`)
- `florence2` → `DownloadAndLoadFlorence2Model` (`model`)
- `interpolation` → `DownloadAndLoadGIMMVFIModel` (`model`)
- `rmbg` → `RMBG` (`model`)
- `smol` → `LayerUtility: LoadSmolLM2Model` (`model`)
- `transparent-background` → `LayerMask: TransparentBackgroundUltra`
(`model`)
- `yolo` → `LayerMask: ObjectDetectorYOLO8` (`yolo_model`)
- `mediapipe` → `LivePortraitLoadMediaPipeCropper` (auto-load)
- `superprompt-v1` → `Superprompt` (auto-load)

These mappings ensure the "Use" button in the model browser correctly
creates the appropriate loader node.

## Test plan
- [ ] Verify "Use" button works for models in each new directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10560-feat-add-model-to-node-mappings-for-10-node-packs-32f6d73d365081d18b6acda078e7fe0b)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:01:08 -07:00
Alexander Brown
68d47af075 fix: normalize legacy prefixed proxyWidget entries on configure (#10573)
## Summary

Normalize legacy prefixed proxyWidget entries during subgraph configure
so nested subgraph widgets resolve correctly.

## Changes

- **What**: Extract `normalizeLegacyProxyWidgetEntry` to strip legacy
`nodeId: innerNodeId: widgetName` prefixes from serialized proxyWidgets
and resolve the correct `disambiguatingSourceNodeId`. Write-back
comparison now checks serialized content (not just array length) so
stale formats are cleaned up even when the entry count is unchanged.

## Review Focus

- The iterative prefix-stripping loop in `resolveLegacyPrefixedEntry` —
it peels one `N: ` prefix per iteration and tries all disambiguator
candidates at each level.
- The write-back condition change from length comparison to
`JSON.stringify` equality.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10573-fix-normalize-legacy-prefixed-proxyWidget-entries-on-configure-32f6d73d365081e886e1c9b3939e3b9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 15:53:10 -07:00
Alexander Brown
897cf9cb8f Mark failing test as in need of fixing (#10572)
CC @christian-byrne

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10572-Mark-failing-test-as-in-need-of-fixing-32f6d73d3650815cba72d19ebc54b0b3)
by [Unito](https://www.unito.io)
2026-03-26 12:23:44 -07:00
jaeone94
e9b01cf479 fix: create initial workflow tab when persistence is disabled (#10565)
## Summary

- When `Comfy.Workflow.Persist` is OFF and storage is empty,
`initializeWorkflow()` returned without creating any workflow tab —
leaving users with no tab and no way to save
- Now falls through to `loadDefaultWorkflow()` so a default temporary
workflow is always created

## Root Cause

In `useWorkflowPersistenceV2.ts`, `initializeWorkflow()` had an early
return when persistence was disabled:

```ts
if (!workflowPersistenceEnabled.value) return
```

This skipped `loadDefaultWorkflow()`, which is responsible for creating
the initial temporary workflow tab via `comfyApp.loadGraphData()` →
`afterLoadNewGraph()` → `workflowStore.createNewTemporary()`.

## Fix

One-line change: `return` → `return loadDefaultWorkflow()`.

## Test plan

- [x] E2E test: verifies `openWorkflows.length >= 1` after reload with
persistence OFF

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10565-fix-create-initial-workflow-tab-when-persistence-is-disabled-32f6d73d365081d5a681c3e019d373c3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-26 11:16:50 -07:00
Terry Jia
bcb39b1bf6 feat: support histogram display in curve widget (#10365)
## Summary
- WidgetCurve reads histogram data from nodeOutputStore (sent by backend
CurveEditor node via ui output) and passes it to CurveEditor
- histogramToPath now supports arbitrary-length bin arrays instead
ofhardcoded 256

need BE changes

## Screenshots (if applicable)
<img width="2431" height="1022" alt="image"
src="https://github.com/user-attachments/assets/8421d4a7-1bff-4269-8b55-649838f9d619"
/>

<img width="2462" height="979" alt="image"
src="https://github.com/user-attachments/assets/191c9163-82ab-4eb2-bb74-0037b3ccd383"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10365-feat-support-histogram-display-in-curve-widget-32a6d73d3650816b9852d73309a0b35f)
by [Unito](https://www.unito.io)
2026-03-26 05:29:08 -04:00
Alexander Brown
d940ea76ee fix: repoint ancestor promoted widget bindings when packing nested subgraphs (#10532)
## Summary

Packing nodes inside a subgraph into a nested subgraph no longer blanks
the parent subgraph node's promoted widget values.

## Changes

- **What**: After `convertToSubgraph` moves interior nodes into a nested
subgraph, `_repointAncestorPromotions` rewrites the promotion store
entries on all host SubgraphNodes so they chain through the new nested
node. `rebuildInputWidgetBindings()` then clears the stale
`input._widget` PromotedWidgetView cache and re-resolves bindings from
current connections.
- The root cause was two separate sets of PromotedWidgetView references:
`node.widgets` (rebuilt from the store — correct) vs `input._widget`
(cached at promotion time — stale). `SubgraphNode.serialize()` reads
`input._widget.value`, which resolved against removed node IDs →
`missing-node` → blank values on the next `checkState` cycle.

## Review Focus

- `_repointAncestorPromotions` iterates all graphs to find host nodes of
the current subgraph type — verify this covers all cases (multiple
instances of the same subgraph type).
- `rebuildInputWidgetBindings()` clears `_promotedViewManager` and
re-resolves — confirm no side effects on event listeners or pending
promotions.
- The nested node gets duplicate promotion entries (from both
`_repointAncestorPromotions` and `promoteRecommendedWidgets` via the
`subgraph-converted` event). `store.promote()` deduplicates via
`isPromoted`, but worth verifying.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10532-fix-repoint-ancestor-promoted-widget-bindings-when-packing-nested-subgraphs-32e6d73d365081109d5aea0660434082)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-03-25 22:43:52 -07:00
Alexander Brown
d397318ad8 Update README for browser testing commands (#10541)
## Summary

Update recommended commands in browser_tests README

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10541-Update-README-for-browser-testing-commands-32f6d73d36508175a675e865a990caed)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-25 21:50:02 -07:00
Christian Byrne
d860d54366 test: add large graph zoom perf test for ResizeObserver baseline (#10478)
## Summary

Adds a `@perf` test that establishes a baseline for ResizeObserver
layout cost during zoom on a large graph (245 nodes).

## Changes

- **What**: New `large graph zoom interaction` perf test that zooms
in/out 30 steps on `large-graph-workflow`, measuring `layouts`,
`layoutDurationMs`, `frameDurationMs`, and `TBT`. Each zoom step
triggers ResizeObserver for all node elements due to CSS scale changes.

## Review Focus

This is **PR 1 of 2** for throttling the ResizeObserver during zoom/pan.
Once this merges and establishes a baseline on main, the fix PR (#10473)
will show a CI-proven delta demonstrating the improvement.

The test follows the same patterns as `large graph pan interaction` and
`canvas zoom sweep`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10478-test-add-large-graph-zoom-perf-test-for-ResizeObserver-baseline-32d6d73d365081169537e557c14d8c51)
by [Unito](https://www.unito.io)
2026-03-25 21:05:36 -07:00
Comfy Org PR Bot
86d202bcc1 1.43.6 (#10540)
Patch version increment to 1.43.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10540-1-43-6-32f6d73d3650810fb3f5c6d4c59130f2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-25 20:31:24 -07:00
Johnpaul Chiwetelu
60b6f78397 chore: bump CI container to 0.0.16 (#10527) 2026-03-26 02:34:27 +01:00
Yourz
6c7c3ea006 fix: tree explorer row height and width overflow (#10501)
## Summary

Fix tree explorer row sizing: consistent row height and prevent
horizontal overflow.

## Changes

- **What**:
1. Reduce node bookmark button from `size-6` (24px) to `size-5` (20px)
so node and folder rows both have 36px height, matching
`TreeVirtualizer` estimate-size and fixing tree list overlap.
2. Change row width from `w-full` to `w-[calc(100%-var(--spacing)*4)]`
to prevent horizontal overflow while keeping `mx-2` margin.

## Review Focus

Pure UI change — no test coverage needed. Verify tree rows render at
consistent height and no horizontal overflow occurs.

## Screenshots (if applicable)

| Before    | After |
| -------- | ------- |
|<img width="1218" height="1662" alt="image"
src="https://github.com/user-attachments/assets/89c799ab-cef3-40ee-88ca-900f5d3c7890"
/>|<img width="407" height="758" alt="image"
src="https://github.com/user-attachments/assets/f9aa4569-aaf8-467f-9dde-a187151af9aa"
/>|

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10501-fix-tree-explorer-row-height-and-width-overflow-32e6d73d3650819aa645c2262693ec62)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 09:20:42 +09:00
pythongosssss
771f68f92a fix: App mode - workaround for alt+m producing alt+μ on mac (#10528)
## Summary

Adds a second keybinding for app mode as on Mac Alt+M produces Alt+μ

## Changes

- **What**: add extra binding entry

## Screenshots (if applicable)

Still shows alt + m in the keybinding panel
<img width="840" height="206" alt="image"
src="https://github.com/user-attachments/assets/f1906bc2-e7c2-4eac-b3ca-5a8a207cc93c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10528-fix-App-mode-workaround-for-alt-m-producing-alt-on-mac-32e6d73d36508176b7d6d918c5bb88f3)
by [Unito](https://www.unito.io)
2026-03-25 16:15:41 -07:00
Comfy Org PR Bot
c5e9e52e5f 1.43.5 (#10489)
Patch version increment to 1.43.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10489-1-43-5-32e6d73d365081c8aed2d65a4823a0c0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-25 14:55:05 -07:00
Christian Byrne
fa1ffcba01 fix: handle clipboard errors in Copy Image and useCopyToClipboard (#9299)
## Summary

Fix unhandled promise rejection ("Document is not focused") in Copy
Image and improve clipboard fallback reliability.

## Changes

- **What**: Two clipboard fixes:
1. `litegraphService.ts`: The "Copy Image" context menu passed async
`writeImage` as a callback to `canvas.toBlob()` without awaiting —
errors became unhandled promise rejections reported in [Sentry
CLOUD-FRONTEND-STAGING-AQ](https://comfy-org.sentry.io/issues/6948073569/).
Extracted `convertToPngBlob` helper that wraps `toBlob` in a proper
Promise so errors propagate to the existing outer try/catch and surface
as a user-facing toast instead of a silent Sentry error.
2. `useCopyToClipboard.ts`: Replaced `useClipboard({ legacy: true })`
with explicit modern→legacy fallback that checks
`document.execCommand('copy')` return value. VueUse's `legacyCopy` sets
`copied.value = true` regardless of whether `execCommand` succeeded,
causing false success toasts.

## Review Focus

- The `convertToPngBlob` helper does the same canvas→PNG work as the old
inline code but properly awaited
- The happy path (PNG clipboard write succeeds first try) is unchanged
- No public API surface changes — verified zero custom node dependencies
via ecosystem code search

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9299-fix-handle-clipboard-errors-in-Copy-Image-and-useCopyToClipboard-3156d73d3650817c8608cba861ee64a9)
by [Unito](https://www.unito.io)
2026-03-25 12:20:33 -07:00
Christian Byrne
f1db1122f3 fix: allow URI drops to bubble from Vue nodes to document handler (#9463)
## Summary

Fix URI drops (e.g. dragging `<img>` thumbnails) onto Vue-rendered nodes
by letting unhandled drops bubble to the document-level `text/uri-list`
fallback in `app.ts`.

## Changes

- **What**: Removed unconditional `.stop` modifier from `@drop` in
`LGraphNode.vue`. `stopPropagation()` is now called conditionally — only
when `onDragDrop` returns `true` (file drop handled). Made `handleDrop`
synchronous since `onDragDrop` returns a plain boolean.

## Review Focus

The key insight is that `onDragDrop` (from `useNodeDragAndDrop`) returns
`false` synchronously for URI drags (no files in `DataTransfer`), so the
event must bubble to reach the document handler that fetches the URI.
The original `async` + `await` pattern would have deferred
`stopPropagation` past the synchronous propagation phase, so
`handleDrop` is now synchronous.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9463-fix-allow-URI-drops-to-bubble-from-Vue-nodes-to-document-handler-31b6d73d36508196a1b3f17e7e4837a9)
by [Unito](https://www.unito.io)
2026-03-25 12:19:38 -07:00
Christian Byrne
f56abb3ecf test: add regression tests for subgraph slot label propagation (#10013)
## Summary

Add regression tests for subgraph slot label propagation. The
OutputSlot.vue fix (adding `slotData.label` to the display template) was
already merged via another PR — this adds tests to prevent future
regressions.

## Changes

- **What**: Two new test files covering the label/localized_name
fallback chain in OutputSlot.vue and SubgraphNode label propagation
through configure() and rename event paths.

## Review Focus

Tests only — no production code changes. Verifies that renamed subgraph
inputs/outputs display correctly in Nodes 2.0 mode.

Fixes #9998

<!-- Pipeline-Ticket: 7d887122-eea5-45f1-b6eb-aed94f708555 -->
2026-03-25 12:19:08 -07:00
Benjamin Lu
95c6811f59 fix: remove unused Playwright hook config args (#10513)
## Summary

Remove the unused `_config` parameter from the Playwright global
setup/teardown hooks and drop the now-unused `FullConfig` imports.

## Changes

- **What**: Simplified `browser_tests/globalSetup.ts` and
`browser_tests/globalTeardown.ts` to match actual usage.

## Review Focus

Verify that removing the unused hook argument does not change Playwright
behavior.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10513-fix-remove-unused-Playwright-hook-config-args-32e6d73d365081d59b63dbbca0596025)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-25 19:18:15 +00:00
Christian Byrne
88079250eb chore: add CI safety rules to backport-management skill (#10164)
Adds lessons learned from a bulk backport session where 69 PRs were
admin-merged without CI checks, shipping 3 test failures to core/1.41.

**Changes:**
- **SKILL.md**: CI Safety Rules section, wave verification with `pnpm
test:unit`, continuous backporting recommendation, Never Admin-Merge
Without CI lesson
- **execution.md**: Wait-for-CI step after automation, `gh pr checks
--watch` for manual cherry-picks, CI Failure Triage section with common
failure categories
- **logging.md**: Wave verification log template, CI failure report
table in session report

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10164-chore-add-CI-safety-rules-to-backport-management-skill-3266d73d365081aa856de1fb85a31887)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-25 12:17:43 -07:00
Alexander Brown
08ea013c51 fix: prune stale proxyWidgets referencing nodes removed by nested subgraph packing (#10390)
## Summary

Prune stale proxyWidgets entries that reference grandchild nodes no
longer present in the outer subgraph after nested packing.

## Changes

- **What**: Filter out proxyWidgets entries during hydration when the
source node doesn't exist in the subgraph. Also skip missing-node
entries in `_pruneStaleAliasFallbackEntries` as defense-in-depth. Write
back cleaned entries so stale data doesn't persist.

## Review Focus

The fix touches two codepaths in `SubgraphNode.ts`:
1. **Hydration** (`_internalConfigureAfterSlots`): Added `getNodeById`
guard before accepting a proxyWidget entry, and broadened the write-back
condition from legacy-only to any filtered entries.
2. **Runtime pruning** (`_pruneStaleAliasFallbackEntries`): Added
early-exit for entries whose source node no longer exists — previously
these survived because failed resolution returned `undefined` which
bypassed the concrete-key comparison.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10390-fix-prune-stale-proxyWidgets-referencing-nodes-removed-by-nested-subgraph-packing-32b6d73d365081e69eedcb2b67d7043d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-25 12:15:52 -07:00
Benjamin Lu
4aae52c2fc test: move getNodeDefs spec into src/scripts (#10503)
## Summary

Move the `getNodeDefs` unit test out of deprecated `tests-ui` and into
`src/scripts` so Vitest discovers and runs it.

## Changes

- **What**: Renamed `tests-ui/tests/scripts/app.getNodeDefs.test.ts` to
`src/scripts/app.getNodeDefs.test.ts`

## Review Focus

Confirm the spec now follows the colocated test convention and is
included by the existing Vitest `include` globs.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10503-test-move-getNodeDefs-spec-into-src-scripts-32e6d73d3650816f9211dc4c20daba4b)
by [Unito](https://www.unito.io)
2026-03-25 12:15:18 -07:00
Benjamin Lu
6b7691422b fix: use named dotenv config imports (#10514)
## Summary

Use named `dotenv` config imports where we were calling
`dotenv.config()` so ESLint and IDEs stop flagging
`import-x/no-named-as-default-member`.

## Changes

- **What**: Replace default `dotenv` imports plus `.config()` member
access with `import { config as dotenvConfig } from 'dotenv'` in browser
test setup/fixture files and the desktop Vite config.
- **What**: Keep behavior unchanged while aligning those files with the
cleaner import form already used elsewhere in the repo.

## Review Focus

This is a no-behavior-change cleanup. The issue was that `dotenv`
exposes `config` both as a named export and as a property on the
default-exported module object, so `import dotenv from 'dotenv';
dotenv.config()` triggers `import-x/no-named-as-default-member` even
though it works at runtime.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10514-fix-use-named-dotenv-config-imports-32e6d73d36508195b346dbcab764a6b8)
by [Unito](https://www.unito.io)
2026-03-25 12:10:20 -07:00
Christian Byrne
437f41c553 perf: add layout/GC metrics + reduce false positives in regression detection (#10477)
## Summary

Add layout duration, style recalc duration, and heap usage metrics to CI
perf reports, while improving statistical reliability to reduce false
positive regressions.

## Changes

- **What**:
- Collect `layoutDurationMs`, `styleRecalcDurationMs`, `heapUsedBytes`
(absolute snapshot) alongside existing metrics
- Add effect size gate (`minAbsDelta`) for integer-quantized count
metrics (style recalcs, layouts, DOM nodes, event listeners) — prevents
z=7.2 false positives from e.g. 11→12 style recalcs
- Switch from mean to **median** for PR metric aggregation — robust to
outlier CI runs that dominate n=3 mean
- Increase historical baseline window from **5 to 15 runs** for more
stable σ estimates
- Reorder reported metrics: layout/style duration first (actionable),
counts and heap after (informational)

## Review Focus

The effect size gate in `classifyChange()` — it now requires both z > 2
AND absolute delta ≥ `minAbsDelta` (when configured) to flag a
regression. This addresses the core false positive issue where integer
metrics with near-zero historical variance produce extreme z-scores for
trivial changes.

Median vs mean tradeoff: median is more robust to outliers but less
sensitive to real shifts — acceptable given n=3 and CI noise levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10477-perf-add-layout-GC-metrics-reduce-false-positives-in-regression-detection-32d6d73d365081daa72cec96d8a07b90)
by [Unito](https://www.unito.io)
2026-03-25 10:16:56 -07:00
ComfyUI Wiki
975393b48b fix: restore is_template tracking for app mode templates (#10252)
## Summary

App mode templates (names ending in `.app`, e.g.
`templates-qwen_multiangle.app`) were never counted as template
executions in Mixpanel because `getExecutionContext` used
`activeWorkflow.filename` for the `knownTemplateNames` lookup — but
`getFilenameDetails` treats `.app.json` as a compound extension and
strips it entirely, leaving `"templates-qwen_multiangle"` instead of
`"templates-qwen_multiangle.app"`. The set lookup always returned
`false`, so every execution was sent with `is_template: false`.

## Changes

- **Fix**: derive the template lookup key from
`fullFilename.replace(/\.json$/i, '')` instead of `filename`, which
preserves the `.app` suffix and correctly matches `knownTemplateNames`
- **Also fixes**: `workflow_name`, `getTemplateByName`, and
`getEnglishMetadata` calls in the same branch now use the corrected name
- **Tests**: three new cases in `MixpanelTelemetryProvider.test.ts` —
regular template, `.app` template (regression), and non-template

## Before / After

| Template name in index | `activeWorkflow.filename` | `fullFilename` →
stripped | `is_template` |
|---|---|---|---|
| `flux-dev` | `flux-dev` | `flux-dev` |  true |
| `templates-qwen_multiangle.app` | `templates-qwen_multiangle`  |
`templates-qwen_multiangle.app`  | fixed: true |

## Review Focus

The change is confined to `getExecutionContext.ts`. `fullFilename` is
always set (it is assigned in `UserFile` constructor from
`getPathDetails`), so no null-safety issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10252-fix-restore-is_template-tracking-for-app-mode-templates-3276d73d365081d4b998edc62ad010dc)
by [Unito](https://www.unito.io)
2026-03-25 21:31:09 +08:00
Luke Mino-Altherr
a44fa1fdd5 fix: tighten date detection regex in formatJsonValue() (#10110)
`formatJsonValue()` uses a loose regex `^\d{4}-\d{2}-\d{2}` to detect
date-like strings, which matches non-date strings like
`"2024-01-01-beta"`.

Changes:
- Require ISO 8601 `T` separator: `/^\d{4}-\d{2}-\d{2}T/`
- Validate parse result with `!Number.isNaN(date.getTime())`
- Use `d()` i18n formatter for consistency with `formatDate()` in the
same file
2026-03-24 19:46:58 -07:00
Christian Byrne
cc3acebceb feat: scaffold Astro 5 website app + design-system base.css [1/3] (#10140)
## Summary
Scaffolds the new apps/website/ Astro 5 + Vue 3 marketing site inside
the monorepo.

## Changes
- apps/website/ with package.json, astro.config.mjs, tsconfig, Nx
targets
- @comfyorg/design-system/css/base.css — brand tokens + fonts (no
PrimeVue)
- pnpm-workspace.yaml catalog entries for Astro deps
- .gitignore and env.d.ts for Astro

## Stack (via Graphite)
- **[1/3] Scaffold** ← this PR
- #10141 [2/3] Layout Shell
- #10142 [3/3] Homepage Sections

Part of the comfy.org website refresh (replacing Framer).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10140-feat-scaffold-Astro-5-website-app-design-system-base-css-1-3-3266d73d365081688dcee0220a03eca4)
by [Unito](https://www.unito.io)
2026-03-24 19:02:10 -07:00
193 changed files with 16554 additions and 663 deletions

View File

@@ -0,0 +1,118 @@
---
name: adr-compliance
description: Checks code changes against Architecture Decision Records, with emphasis on ECS (ADR 0008) and command-pattern (ADR 0003) compliance
severity-default: medium
tools: [Read, Grep, glob]
---
Check that code changes are consistent with the project's Architecture Decision Records in `docs/adr/`.
## Priority 1: ECS and Command-Pattern Compliance (ADR 0008 + ADR 0003)
These are the primary architectural guardrails. Every entity/litegraph change must be checked against them.
### Command Pattern (ADR 0003)
All entity state mutations MUST be expressible as **serializable, idempotent, deterministic commands**. This is required for CRDT sync, undo/redo, cross-environment portability, and gateway backends.
Flag:
- **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside of a store or command. All spatial data flows through `layoutStore` commands.
- **Imperative fire-and-forget mutation** — Any new API that mutates entity state as a side effect rather than producing a serializable command object. Systems should produce command batches, not execute mutations directly.
- **Void-returning mutation APIs** — New entity mutation functions that return `void` instead of a result type (`{ status: 'applied' | 'rejected' | 'no-op' }`). Commands need error/rejection semantics.
- **Auto-incrementing IDs in new entity code** — New entity creation using auto-increment counters without acknowledging the CRDT collision problem. Concurrent environments need globally unique, stable identifiers.
### ECS Architecture (ADR 0008)
The graph domain model is migrating to ECS. New code must not make the migration harder.
Flag:
- **God-object growth** — New methods/properties added to `LGraphNode` (~4k lines), `LGraphCanvas` (~9k lines), `LGraph` (~3k lines), or `Subgraph`. Extract to systems, stores, or composables instead.
- **Mixed data and behavior** — New component-like data structures that contain methods or back-references to parent entities. ECS components are plain data objects.
- **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
All entity data access should move toward centralized query patterns, not instance property access.
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)`.
- **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.
### Extension Ecosystem Impact
Entity API changes affect 40+ custom node repos. Changes to these patterns require an extension migration path.
Flag when changed without migration guidance:
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
- `onConnectInput` / `onConnectOutput` validation hooks
- `onWidgetChanged` handlers
- `node.widgets.find(w => w.name === ...)` patterns
- `node.serialize` overrides
- `graph._version++` direct mutation
- `getNodeById` usage patterns
## Priority 2: General ADR Compliance
For all other ADRs, iterate through each file in `docs/adr/` and extract the core lesson. Ensure changed code does not contradict accepted ADRs. Flag contradictions with proposed ADRs as directional guidance.
### How to Apply
1. Read `docs/adr/README.md` to get the full ADR index
2. For each ADR, read the Decision and Consequences sections
3. Check the diff against each ADR's constraints
4. Only flag ACTUAL violations in changed code, not pre-existing patterns
### Skip List
These ADRs can be skipped for most reviews (they cover completed or narrow-scope decisions):
- **ADR 0004** (Rejected — Fork PrimeVue) — only relevant if someone proposes forking PrimeVue again
## How to Check
1. Identify changed files in the entity/litegraph layer: `src/lib/litegraph/`, `src/ecs/`, `src/platform/`, entity-related stores
2. For Priority 1 patterns, use targeted searches:
```
# Direct position mutation
Grep: pattern="\.pos\s*=" path="src/lib/litegraph"
Grep: pattern="\.size\s*=" path="src/lib/litegraph"
# God object growth (new methods)
Grep: pattern="(class LGraphNode|class LGraphCanvas|class LGraph\b)" path="src/lib/litegraph"
# Version mutation
Grep: pattern="_version\+\+" path="src/lib/litegraph"
# Extension callback changes
Grep: pattern="on(ConnectionsChange|Removed|Added|Configure|ConnectInput|ConnectOutput|WidgetChanged)" path="src/lib/litegraph"
```
3. For Priority 2, read `docs/adr/` files and check for contradictions
## Severity Guidelines
| Issue | Severity |
| -------------------------------------------------------- | -------- |
| Imperative mutation API without command-pattern wrapper | high |
| New god-object method on LGraphNode/LGraphCanvas/LGraph | high |
| Breaking extension callback without migration path | high |
| New circular entity dependency | high |
| Direct spatial mutation bypassing command pattern | medium |
| Mixed data/behavior in component-like structures | medium |
| New OOP inheritance pattern for entities | medium |
| Contradicts accepted ADR direction | medium |
| Contradicts proposed ADR direction without justification | low |
## Rules
- Only flag ACTUAL violations in changed code, not pre-existing patterns
- If a change explicitly acknowledges an ADR tradeoff in comments or PR description, lower severity
- Proposed ADRs carry less weight than accepted ones — flag as directional guidance
- Reference the specific ADR number in every finding

View File

@@ -0,0 +1,94 @@
# ADR Compliance Audit
Audit the current changes (or a specified PR) for compliance with Architecture Decision Records.
## Step 1: Gather the Diff
- If a PR number is provided, run: `gh pr diff $PR_NUMBER`
- Otherwise, run: `git diff origin/main...HEAD` (or `git diff --cached` for staged changes)
## Step 2: Priority 1 — ECS and Command-Pattern Compliance
Read these documents for context:
```
docs/adr/0003-crdt-based-layout-system.md
docs/adr/0008-entity-component-system.md
docs/architecture/ecs-target-architecture.md
docs/architecture/ecs-migration-plan.md
docs/architecture/appendix-critical-analysis.md
```
### Check A: Command Pattern (ADR 0003)
Every entity state mutation must be a **serializable, idempotent, deterministic command** — replayable, undoable, transmittable over CRDT.
Flag:
1. **Direct spatial mutation**`node.pos = ...`, `node.size = ...`, `group.pos = ...` outside a store/command
2. **Imperative fire-and-forget APIs** — Functions that mutate entity state as side effects rather than producing serializable command objects. Systems should produce command batches, not execute mutations directly.
3. **Void-returning mutation APIs** — Entity mutations returning `void` instead of `{ status: 'applied' | 'rejected' | 'no-op' }`
4. **Auto-increment IDs** — New entity creation via counters without addressing CRDT collision. Concurrent environments need globally unique identifiers.
5. **Missing transaction semantics** — Multi-entity operations without atomic grouping (e.g., node removal = 10+ deletes with no rollback on failure)
### Check B: ECS Architecture (ADR 0008)
Flag:
1. **God-object growth** — New methods/properties on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
2. **Mixed data/behavior** — Component-like structures with methods or back-references
3. **OOP instance patterns** — New `node.someProperty` or `node.someMethod()` for data that should be a World component
4. **OOP inheritance** — New entity subclasses instead of component composition
5. **Circular entity deps** — New `LGraph``Subgraph`, `LGraphNode``LGraphCanvas` circular imports
6. **Direct `_version++`** — Mutating private version counter instead of through public API
### Check C: Extension Ecosystem Impact
If any of these patterns are changed, flag and require migration guidance:
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
- `onConnectInput` / `onConnectOutput` validation hooks
- `onWidgetChanged` handlers
- `node.widgets.find(w => w.name === ...)` access patterns
- `node.serialize` overrides
- `graph._version++` direct mutation
Reference: 40+ custom node repos depend on these (rgthree-comfy, ComfyUI-Impact-Pack, cg-use-everywhere, etc.)
## Step 3: Priority 2 — General ADR Compliance
1. Read `docs/adr/README.md` for the full ADR index
2. For each ADR (except skip list), read the Decision section
3. Check the diff for contradictions
4. Only flag ACTUAL violations in changed code
**Skip list**: ADR 0004 (Rejected — Fork PrimeVue)
## Step 4: Generate Report
```
## ADR Compliance Audit Report
### Summary
- Files audited: N
- Priority 1 findings: N (command-pattern: N, ECS: N, ecosystem: N)
- Priority 2 findings: N
### Priority 1: Command Pattern & ECS
(List each with ADR reference, file, line, description)
### Priority 1: Extension Ecosystem Impact
(List each changed callback/API with affected custom node repos)
### Priority 2: General ADR Compliance
(List each with ADR reference, file, line, description)
### Compliant Patterns
(Note changes that positively align with ADR direction)
```
## Severity
- **Must fix**: Contradicts accepted ADR, or introduces imperative mutation API without command-pattern wrapper, or breaks extension callback without migration path
- **Should discuss**: Contradicts proposed ADR direction — either align or propose ADR amendment
- **Note**: Surfaces open architectural question not yet addressed by ADRs

View File

@@ -18,12 +18,20 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
## System Context
| Item | Value |
| -------------- | ------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
| Tracking dir | `~/temp/backport-session/` |
| Item | Value |
| -------------- | --------------------------------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
| Tracking dir | `~/temp/backport-session/` |
## CI Safety Rules
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
## Branch Scope Rules
@@ -108,11 +116,15 @@ git fetch origin TARGET_BRANCH
# Quick smoke check: does the branch build?
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
git worktree remove /tmp/verify-TARGET --force
```
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
### Never Admin-Merge Without CI
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
## Continuous Backporting Recommendation

View File

@@ -19,23 +19,44 @@ done
# Wait 3 minutes for automation
sleep 180
# Check which got auto-PRs
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
```
## Step 2: Review & Merge Clean Auto-PRs
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
## Step 2: Wait for CI & Merge Clean Auto-PRs
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
```bash
for pr in $AUTO_PRS; do
# Check size
gh pr view $pr --json title,additions,deletions,changedFiles \
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
# Admin merge
gh pr merge $pr --squash --admin
sleep 3
# Wait for CI to complete (~45 minutes for full suite)
sleep 2700
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
# For PRs still open, check CI status
for pr in $STILL_OPEN_PRS; do
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
if [ "$CI_FAILED" != "0" ]; then
# CI failed — collect details for triage
echo "PR #$pr — CI FAILED:"
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
elif [ "$CI_PENDING" != "0" ]; then
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
else
# All checks passed but didn't auto-merge (race condition or label issue)
gh pr merge $pr --squash --admin
sleep 3
fi
done
```
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
## Step 3: Manual Worktree for Conflicts
```bash
@@ -63,6 +84,13 @@ for PR in ${CONFLICT_PRS[@]}; do
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
# Wait for CI before merging — NEVER admin-merge without CI passing
echo "Waiting for CI on PR #$NEW_PR..."
gh pr checks $NEW_PR --watch --fail-fast || {
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
continue
}
gh pr merge $NEW_PR --squash --admin
sleep 3
done
@@ -82,7 +110,7 @@ After completing all PRs in a wave for a target branch:
git fetch origin TARGET_BRANCH
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
git worktree remove /tmp/verify-TARGET --force
```
@@ -132,7 +160,8 @@ git rebase origin/TARGET_BRANCH
# Resolve new conflicts
git push --force origin backport-$PR-to-TARGET
sleep 20 # Wait for GitHub to recompute merge state
gh pr merge $PR --squash --admin
# Wait for CI after rebase before merging
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
```
## Lessons Learned
@@ -146,5 +175,31 @@ gh pr merge $PR --squash --admin
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
12. **Never admin-merge without CI**`--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
## CI Failure Triage
When CI fails on a backport PR, present failures to the user using this template:
```markdown
### PR #XXXX — CI Failed
- **Failing check:** test / lint / typecheck
- **Error:** (summary of the failure message)
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
```
Common failure categories:
| Category | Example | Resolution |
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
| Type error | Interface changed on main but not branch | May need manual adaptation |
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.

View File

@@ -5,9 +5,9 @@
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
@@ -19,6 +19,7 @@ Track verification results per wave:
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
@@ -41,6 +42,11 @@ Track verification results per wave:
| PR# | Branch | Conflict Type | Resolution Summary |
## CI Failure Report
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
| --- | ------ | ------------- | ------------- | ----- | ---------- |
## Automation Performance
| Metric | Value |

View File

@@ -28,3 +28,21 @@ reviews:
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
This check applies ONLY when the PR modifies files under `src/lib/litegraph/`, `src/ecs/`, or files related to graph entities (nodes, links, widgets, slots, reroutes, groups, subgraphs).
If none of those paths appear in the changed files, pass immediately.
When applicable, check for:
1. **Command pattern (ADR 0003)**: Entity state mutations must be serializable, idempotent, deterministic commands — not imperative fire-and-forget side effects. Flag direct spatial mutation (`node.pos =`, `node.size =`, `group.pos =`) outside of a store or command, and any new void-returning mutation API that should produce a command object.
2. **God-object growth (ADR 0008)**: New methods/properties added to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` that add responsibilities rather than extracting/migrating existing ones.
3. **ECS data/behavior separation (ADR 0008)**: Component-like data structures that contain methods or back-references to parent entities. ECS components must be plain data. New OOP instance patterns (`node.someProperty`, `node.someMethod()`) for data that should be a World component.
4. **Extension ecosystem (ADR 0008)**: Changes to extension-facing callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure`, `onConnectInput/Output`, `onWidgetChanged`), `node.widgets` access, `node.serialize` overrides, or `graph._version++` without migration guidance. These affect 40+ custom node repos.
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.

View File

@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -87,7 +87,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -180,7 +180,7 @@ jobs:
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -66,6 +66,7 @@ dist.zip
/temp/
/tmp/
.superpowers/
# Generated JSON Schemas
/schemas/

View File

@@ -231,6 +231,18 @@ See @docs/testing/\*.md for detailed patterns.
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records
All architectural decisions are documented in `docs/adr/`. Code changes must be consistent with accepted ADRs. Proposed ADRs indicate design direction and should be treated as guidance. See `.agents/checks/adr-compliance.md` for automated validation rules.
### 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.
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.
## Project Philosophy
- Follow good software engineering principles

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codebase Caverns — ComfyUI Architecture Adventure</title>
<meta
name="description"
content="A prestige-driven architecture adventure game. Discover problems, learn patterns, make decisions, and watch the consequences unfold."
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,60 @@
{
"name": "@comfyorg/architecture-adventure",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "catalog:",
"vite": "catalog:"
},
"nx": {
"tags": [
"scope:docs",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/architecture-adventure",
"command": "vite"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"command": "vite build --config apps/architecture-adventure/vite.config.ts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/architecture-adventure",
"command": "vite preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/architecture-adventure",
"command": "tsc --noEmit"
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
function main(): void {
const app = document.getElementById('app')
if (!app) throw new Error('Missing #app element')
app.textContent = 'Codebase Caverns v2 — Loading...'
}
main()

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "vite.config.ts"]
}

View File

@@ -0,0 +1,26 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
root: projectRoot,
base: './',
build: {
target: 'es2022',
outDir: 'dist',
assetsInlineLimit: 1_000_000,
cssCodeSplit: false,
rolldownOptions: {
output: {
inlineDynamicImports: true
}
}
},
resolve: {
alias: {
'@': path.resolve(projectRoot, 'src')
}
}
})

View File

@@ -1,6 +1,6 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
dotenv.config()
dotenvConfig()
const projectRoot = fileURLToPath(new URL('.', import.meta.url))

2
apps/website/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
.astro/

View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'astro/config'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
integrations: [vue()],
vite: {
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false
}
}
})

80
apps/website/package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "@comfyorg/website",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@comfyorg/design-system": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/vue": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
}
}
}
}

Binary file not shown.

Binary file not shown.

1
apps/website/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';

View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example
**Always use UI mode for development:**
```bash
pnpm exec playwright test --ui
pnpm test:browser:local --ui
```
UI mode features:
@@ -91,29 +91,8 @@ UI mode features:
For CI or headless testing:
```bash
pnpm exec playwright test # Run all tests
pnpm exec playwright test widget.spec.ts # Run specific test file
```
### Local Development Config
For debugging, you can try adjusting these settings in `playwright.config.ts`:
```typescript
export default defineConfig({
// VERY HELPFUL: Skip screenshot tests locally
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
timeout: 30000, // Longer timeout for breakpoints
use: {
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
video: 'on' // Always record video (CI uses 'retain-on-failure')
},
})
pnpm test:browser:local # Run all tests
pnpm test:browser:local widget.spec.ts # Run specific test file
```
## Test Structure
@@ -385,7 +364,7 @@ export default defineConfig({
Option 2 - Generate local baselines for comparison:
```bash
pnpm exec playwright test --update-snapshots
pnpm test:browser:local --update-snapshots
```
### Creating New Screenshot Baselines

View File

@@ -0,0 +1,817 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 61,
"last_link_id": 70,
"nodes": [
{
"id": 35,
"type": "MarkdownNote",
"pos": [-424.0076397768001, 199.99406275798367],
"size": [510, 774],
"flags": {
"collapsed": false
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#000"
},
{
"id": 9,
"type": "SaveImage",
"pos": [569.9875743118757, 199.99406275798367],
"size": [780, 660],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 62
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z-image-turbo"]
},
{
"id": 57,
"type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"pos": [128.01215102992103, 199.99406275798367],
"size": [400, 470],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "prompt",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [62]
}
],
"properties": {
"proxyWidgets": [
["27", "text"],
["13", "width"],
["13", "height"],
["28", "unet_name"],
["30", "clip_name"],
["29", "vae_name"],
["3", "steps"],
["3", "control_after_generate"]
],
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
}
],
"links": [[62, 57, 0, 9, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 61,
"lastLinkId": 70,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Text to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [-80, 425, 120, 180]
},
"outputNode": {
"id": -20,
"bounding": [1490, 415, 120, 60]
},
"inputs": [
{
"id": "fb178669-e742-4a53-8a69-7df59834dfd8",
"name": "text",
"type": "STRING",
"linkIds": [34],
"label": "prompt",
"pos": [20, 445]
},
{
"id": "dd780b3c-23e9-46ff-8469-156008f42e5a",
"name": "width",
"type": "INT",
"linkIds": [35],
"pos": [20, 465]
},
{
"id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc",
"name": "height",
"type": "INT",
"linkIds": [36],
"pos": [20, 485]
},
{
"id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596",
"name": "unet_name",
"type": "COMBO",
"linkIds": [38],
"pos": [20, 505]
},
{
"id": "f362d639-d412-4b5d-8490-1e9995dc5f82",
"name": "clip_name",
"type": "COMBO",
"linkIds": [39],
"pos": [20, 525]
},
{
"id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf",
"name": "vae_name",
"type": "COMBO",
"linkIds": [40],
"pos": [20, 545]
},
{
"id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892",
"name": "steps",
"type": "INT",
"linkIds": [70],
"pos": [20, 565]
}
],
"outputs": [
{
"id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [16],
"localized_name": "IMAGE",
"pos": [1510, 435]
}
],
"widgets": [],
"nodes": [
{
"id": 30,
"type": "CLIPLoader",
"pos": [110, 330],
"size": [270, 106],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "clip_name",
"name": "clip_name",
"type": "COMBO",
"widget": {
"name": "clip_name"
},
"link": 39
}
],
"outputs": [
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [28]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"]
},
{
"id": 29,
"type": "VAELoader",
"pos": [110, 480],
"size": [270, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": 40
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [27]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["ae.safetensors"]
},
{
"id": 33,
"type": "ConditioningZeroOut",
"pos": [640, 620],
"size": [204.134765625, 26],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 32
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [33]
}
],
"properties": {
"Node name for S&R": "ConditioningZeroOut",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1220, 160],
"size": [210, 46],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 27
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [16]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 28,
"type": "UNETLoader",
"pos": [110, 200],
"size": [270, 82],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "unet_name",
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 38
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [26]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z_image_turbo_bf16.safetensors", "default"]
},
{
"id": 27,
"type": "CLIPTextEncode",
"pos": [430, 200],
"size": [410, 370],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 28
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 34
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [30, 32]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. "
]
},
{
"id": 13,
"type": "EmptySD3LatentImage",
"pos": [110, 630],
"size": [260, 110],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": 35
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": 36
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [17]
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [1024, 1024, 1]
},
{
"id": 11,
"type": "ModelSamplingAuraFlow",
"pos": [880, 160],
"size": [310, 60],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 26
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [3]
},
{
"id": 3,
"type": "KSampler",
"pos": [880, 270],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 30
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 33
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 17
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": 70
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [14]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
0,
"randomize",
8,
1,
"res_multistep",
"simple",
1
]
}
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [100, 560, 290, 200],
"color": "#3f789e",
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [410, 130, 450, 540],
"color": "#3f789e",
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [100, 130, 290, 413.6],
"color": "#3f789e",
"flags": {}
}
],
"links": [
{
"id": 32,
"origin_id": 27,
"origin_slot": 0,
"target_id": 33,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 26,
"origin_id": 28,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 27,
"origin_id": 29,
"origin_slot": 0,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 13,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 30,
"origin_id": 27,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 33,
"origin_id": 33,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 17,
"origin_id": 13,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 28,
"origin_id": 30,
"origin_slot": 0,
"target_id": 27,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 16,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 34,
"origin_id": -10,
"origin_slot": 0,
"target_id": 27,
"target_slot": 1,
"type": "STRING"
},
{
"id": 35,
"origin_id": -10,
"origin_slot": 1,
"target_id": 13,
"target_slot": 0,
"type": "INT"
},
{
"id": 36,
"origin_id": -10,
"origin_slot": 2,
"target_id": 13,
"target_slot": 1,
"type": "INT"
},
{
"id": 38,
"origin_id": -10,
"origin_slot": 3,
"target_id": 28,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 39,
"origin_id": -10,
"origin_slot": 4,
"target_id": 30,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 40,
"origin_id": -10,
"origin_slot": 5,
"target_id": 29,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 70,
"origin_id": -10,
"origin_slot": 6,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "LG"
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6488294314381271,
"offset": [733, 392.7886597938144]
},
"frontendVersion": "1.43.4",
"workflowRendererVersion": "LG",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -0,0 +1,599 @@
{
"id": "legacy-prefix-test-workflow",
"revision": 0,
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [["6", "6: 3: string_a"]]
},
"widgets_values": [""]
},
{
"id": 2,
"type": "PreviewAny",
"pos": [1335, 429],
"size": [250, 145.5],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [356, 450],
"size": [225, 121.5],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 6,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1315, 432.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1335, 452.5]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "StringConcatenate",
"pos": [815, 373],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [955, 775],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [313, 685],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 12,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [680, 774, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1320, 774, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [780, 794]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1340, 794]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "StringConcatenate",
"pos": [860, 719],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "PrimitiveStringMultiline",
"pos": [401, 973],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1046, 985],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 6,
"origin_slot": 0,
"target_id": 5,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 5,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Innermost Subgraph",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1330, 1222, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1350, 1242]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "StringConcatenate",
"pos": [870, 1038],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [442, 1296],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-7, 144]
},
"frontendVersion": "1.38.13"
},
"version": 0.4
}

View File

@@ -0,0 +1,555 @@
{
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
"revision": 0,
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": [1451.0058559453123, 189.0019842294924],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [25.988896564209426, 473.9973077158204],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [11]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [10]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"pos": [711.776576770508, 420.55569028417983],
"size": [400, 293],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 10
},
{
"name": "model",
"type": "MODEL",
"link": 11
},
{
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [13]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"],
["3", "seed"]
]
},
"widgets_values": []
}
],
"links": [
[10, 4, 1, 10, 0, "CLIP"],
[11, 4, 0, 10, 1, "MODEL"],
[12, 4, 2, 10, 2, "VAE"],
[13, 10, 0, 9, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233, 404.5, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1494, 424.5, 120, 60]
},
"inputs": [
{
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
"name": "clip",
"type": "CLIP",
"linkIds": [14],
"localized_name": "clip",
"pos": [333, 424.5]
},
{
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [333, 444.5]
},
{
"id": "e61199fa-9113-4532-a3d9-879095969171",
"name": "vae",
"type": "VAE",
"linkIds": [8],
"localized_name": "vae",
"pos": [333, 464.5]
}
],
"outputs": [
{
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [9],
"localized_name": "IMAGE",
"pos": [1514, 444.5]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473.007643669922, 609.0214689174805],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [862.990643669922, 185.9853293300783],
"size": [400, 317],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 16
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209.0062878349609, 188.00400724755877],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 11,
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"pos": [485.5190761650391, 283.9247189174806],
"size": [400, 237],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [15]
},
{
"localized_name": "CONDITIONING_1",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"links": [16]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 2,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": 11,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
},
{
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
]
},
"inputs": [
{
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
"name": "clip",
"type": "CLIP",
"linkIds": [5, 3],
"localized_name": "clip",
"pos": [55, 20]
}
],
"outputs": [
{
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "CONDITIONING",
"pos": [20, 20]
},
{
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "CONDITIONING_1",
"pos": [20, 40]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413.01228575000005, 388.98593823266606],
"size": [425, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [414.99053247091683, 185.9946096918335],
"size": [423, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6830134553650709,
"offset": [-203.70966200000038, 259.92420099999975]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@ import type {
Page
} from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import { TestIds } from './selectors'
import { NodeBadgeMode } from '../../src/types/nodeSource'
@@ -40,7 +40,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper'
import type { NodeReference } from './utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
dotenv.config()
dotenvConfig()
class ComfyPropertiesPanel {
readonly root: Locator

View File

@@ -25,13 +25,15 @@ export class DragDropHelper {
url?: string
dropPosition?: Position
waitForUpload?: boolean
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
const {
dropPosition = { x: 100, y: 100 },
fileName,
url,
waitForUpload = false
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !url)
@@ -43,7 +45,8 @@ export class DragDropHelper {
fileType?: string
buffer?: Uint8Array | number[]
url?: string
} = { dropPosition }
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = this.assetPath(fileName)
@@ -115,15 +118,17 @@ export class DragDropHelper {
)
}
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
if (!params.preserveNativePropagation) {
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
}
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
@@ -154,7 +159,10 @@ export class DragDropHelper {
async dragAndDropURL(
url: string,
options: { dropPosition?: Position } = {}
options: {
dropPosition?: Position
preserveNativePropagation?: boolean
} = {}
): Promise<void> {
return this.dragAndDropExternalResource({ url, ...options })
}

View File

@@ -23,6 +23,7 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -190,6 +191,7 @@ export class PerformanceHelper {
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize'),
heapUsedBytes: after.JSHeapUsedSize,
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,

View File

@@ -62,6 +62,8 @@ export const TestIds = {
colorRed: 'red'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',

View File

@@ -1,11 +1,10 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import { backupPath } from './utils/backupUtils'
dotenv.config()
dotenvConfig()
export default function globalSetup(_config: FullConfig) {
export default function globalSetup() {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])

View File

@@ -1,12 +1,11 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { config as dotenvConfig } from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenv.config()
dotenvConfig()
export default function globalTeardown(_config: FullConfig) {
export default function globalTeardown() {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {

View File

@@ -10,6 +10,7 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -720,6 +721,19 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Creates initial workflow tab when persistence is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
await comfyPage.setup()
const openCount = await comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.openWorkflows.length
})
expect(openCount).toBeGreaterThanOrEqual(1)
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {

View File

@@ -67,5 +67,44 @@ test.describe(
)
})
})
test.fixme('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {
const fakeUrl = 'https://example.com/workflow.png'
await comfyPage.page.route(fakeUrl, (route) =>
route.fulfill({
path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png')
})
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
const node = comfyPage.vueNodes.getNodeByTitle('KSampler')
const box = await node.boundingBox()
expect(box).not.toBeNull()
const dropPosition = {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2
}
await comfyPage.dragDrop.dragAndDropURL(fakeUrl, {
dropPosition,
preserveNativePropagation: true
})
await comfyPage.page.waitForFunction(
(prevCount) => window.app!.graph.nodes.length !== prevCount,
initialNodeCount,
{ timeout: 10000 }
)
const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(newNodeCount).not.toBe(initialNodeCount)
})
}
)

View File

@@ -154,6 +154,38 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph zoom interaction', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Position mouse at center so wheel events hit the canvas
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.perf.startMeasuring()
// Zoom in 30 steps then out 30 steps — each step triggers
// ResizeObserver for all ~245 node elements due to CSS scale change.
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, -100)
await comfyPage.nextFrame()
}
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, 100)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-graph-zoom')
recordMeasurement(m)
console.log(
`Large graph zoom: ${m.layouts} layouts, ${m.layoutDurationMs.toFixed(1)}ms layout, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,122 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Sidebar splitter width independence', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Sidebar.UnifiedWidth', true)
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
})
async function dismissToasts(comfyPage: ComfyPage) {
const buttons = await comfyPage.page.locator('.p-toast-close-button').all()
for (const btn of buttons) {
await btn.click({ timeout: 2000 }).catch(() => {})
}
// Brief wait for animations
await comfyPage.nextFrame()
}
async function dragGutter(comfyPage: ComfyPage, deltaX: number) {
const gutter = comfyPage.page
.locator('.p-splitter-gutter:not(.hidden)')
.first()
await expect(gutter).toBeVisible()
const box = await gutter.boundingBox()
expect(box).not.toBeNull()
const centerX = box!.x + box!.width / 2
const centerY = box!.y + box!.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(centerX + deltaX, centerY, { steps: 10 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
}
async function openSidebarAt(
comfyPage: ComfyPage,
location: 'left' | 'right'
) {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
await comfyPage.nextFrame()
await dismissToasts(comfyPage)
await comfyPage.menu.nodeLibraryTab.open()
}
test('left and right sidebars use separate localStorage keys', async ({
comfyPage
}) => {
// Open sidebar on the left and resize it
await openSidebarAt(comfyPage, 'left')
await dragGutter(comfyPage, 100)
// Read the sidebar panel width after resize
const leftSidebar = comfyPage.page.locator('.side-bar-panel').first()
const leftWidth = (await leftSidebar.boundingBox())!.width
// Close sidebar, switch to right, open again
await comfyPage.menu.nodeLibraryTab.close()
await openSidebarAt(comfyPage, 'right')
// Right sidebar should use its default width, not the left's resized width
const rightSidebar = comfyPage.page.locator('.side-bar-panel').first()
await expect(rightSidebar).toBeVisible()
const rightWidth = (await rightSidebar.boundingBox())!.width
// The right sidebar should NOT match the left's resized width.
// We dragged the left sidebar 100px wider, so there should be a noticeable
// difference between the left (resized) and right (default) widths.
expect(Math.abs(rightWidth - leftWidth)).toBeGreaterThan(50)
})
test('localStorage keys include sidebar location', async ({ comfyPage }) => {
// Open sidebar on the left and resize
await openSidebarAt(comfyPage, 'left')
await dragGutter(comfyPage, 50)
// Left-only sidebar should use the legacy key (no location suffix)
const leftKey = await comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar')
)
expect(leftKey).not.toBeNull()
// Switch to right and resize
await comfyPage.menu.nodeLibraryTab.close()
await openSidebarAt(comfyPage, 'right')
await dragGutter(comfyPage, -50)
// Right sidebar should use a different key with location suffix
const rightKey = await comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar-right')
)
expect(rightKey).not.toBeNull()
// Both keys should exist independently
const leftKeyStillExists = await comfyPage.page.evaluate(() =>
localStorage.getItem('unified-sidebar')
)
expect(leftKeyStillExists).not.toBeNull()
})
test('normalized panel sizes sum to approximately 100%', async ({
comfyPage
}) => {
await openSidebarAt(comfyPage, 'left')
await dragGutter(comfyPage, 80)
// Check that saved sizes sum to ~100%
const sizes = await comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('unified-sidebar')
return raw ? JSON.parse(raw) : null
})
expect(sizes).not.toBeNull()
expect(Array.isArray(sizes)).toBe(true)
const sum = (sizes as number[]).reduce((a, b) => a + b, 0)
expect(sum).toBeGreaterThan(99)
expect(sum).toBeLessThanOrEqual(101)
})
})

View File

@@ -0,0 +1,99 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy prefixed proxyWidget normalization',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('Failed to resolve legacy -1') ||
text.includes('No link found') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
test('Promoted widget renders with normalized name, not legacy prefix', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// The promoted widget should render with the clean name "string_a",
// not the legacy-prefixed "6: 3: string_a".
const promotedWidget = outerNode
.getByLabel('string_a', { exact: true })
.first()
await expect(promotedWidget).toBeVisible()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// Both widget rows should be valid "string_a" widgets — no stale
// "Disconnected" placeholders from unresolved legacy entries.
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(row.getByLabel('string_a', { exact: true })).toBeVisible()
}
})
test('Promoted widget value is editable as a text input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
})
}
)

View File

@@ -142,12 +142,12 @@ test.describe(
})
})
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -182,8 +182,8 @@ test.describe(
})
})
.toEqual({
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})

View File

@@ -0,0 +1,195 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
const HOST_NODE_ID = '57'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
const heightWidget = nodeLocator
.getByLabel('height', { exact: true })
.first()
const stepsWidget = nodeLocator
.getByLabel('steps', { exact: true })
.first()
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
const widthControls =
comfyPage.vueNodes.getInputNumberControls(widthWidget)
const heightControls =
comfyPage.vueNodes.getInputNumberControls(heightWidget)
const stepsControls =
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
await expect(async () => {
await expect(widthControls.input).toHaveValue('1024')
await expect(heightControls.input).toHaveValue('1024')
await expect(stepsControls.input).toHaveValue('8')
await expect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Enter the subgraph via Vue node button
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// 3. Disable Vue nodes for canvas operations (select all + convert)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// 4. Select all interior nodes and convert to nested subgraph
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
// 5. Navigate back to root graph and trigger a checkState cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeAfter).toBeVisible()
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
const heightAfter = nodeAfter
.getByLabel('height', { exact: true })
.first()
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
const widthControlsAfter =
comfyPage.vueNodes.getInputNumberControls(widthAfter)
const heightControlsAfter =
comfyPage.vueNodes.getInputNumberControls(heightAfter)
const stepsControlsAfter =
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
await expect(async () => {
await expect(widthControlsAfter.input).toHaveValue('1024')
await expect(heightControlsAfter.input).toHaveValue('1024')
await expect(stepsControlsAfter.input).toHaveValue('8')
await expect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// Enter the subgraph via Vue node button, then disable for canvas ops
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// Verify all proxyWidgets entries resolve
await expect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)

View File

@@ -0,0 +1,51 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await expect(widgets).toHaveCount(1)
await expect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
})
}
)

View File

@@ -0,0 +1,239 @@
# 8. Entity Component System
Date: 2026-03-23
## Status
Proposed
## 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.
This coupling makes it difficult to:
- Add cross-cutting concerns (undo/redo, serialization, multiplayer CRDT sync, rendering optimization) without modifying every class
- Test individual aspects of an entity in isolation
- Evolve rendering, serialization, and execution logic independently
- Implement the CRDT-based layout system proposed in [ADR 0003](0003-crdt-based-layout-system.md)
An Entity Component System (ECS) separates **identity** (entities), **data** (components), and **behavior** (systems), enabling each concern to evolve independently.
### Current pain points
- **God objects**: `LGraphNode` (~2000+ lines) mixes position, rendering, connectivity, execution, serialization, and input handling
- **Circular dependencies**: `LGraph``Subgraph`, `LGraphNode``LGraphCanvas`, requiring careful import ordering and barrel exports
- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management
## Decision
Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.
### Entity Taxonomy
Six entity kinds, each with a branded ID type:
| Entity Kind | Current Class(es) | Current ID | Branded ID |
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
Subgraphs are not a separate entity kind. A subgraph is a node with a `SubgraphStructure` component. See [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) for the full design rationale.
### Branded ID Design
Each entity kind gets a nominal/branded type wrapping its underlying primitive. The brand prevents accidental cross-kind usage at compile time while remaining structurally compatible with existing ID types:
```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' }
// Scope identifier, not an entity ID
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`).
### Component Decomposition
Components are plain data objects — no methods, no back-references to parent entities. Systems query components to implement behavior.
#### Shared Components
- **Position** — `{ pos: Point }` — used by Node, Reroute, Group
- **Dimensions** — `{ size: Size, bounding: Rectangle }` — used by Node, Group
- **Visual** — rendering properties specific to each entity kind (separate interfaces, shared naming convention)
#### Node
| Component | Data (from `LGraphNode`) |
| ----------------- | --------------------------------------------------- |
| `Position` | `pos` |
| `Dimensions` | `size`, `_bounding` |
| `NodeVisual` | `color`, `bgcolor`, `boxcolor`, `title` |
| `NodeType` | `type`, `category`, `nodeData`, `description` |
| `Connectivity` | slot entity refs (replaces `inputs[]`, `outputs[]`) |
| `Execution` | `order`, `mode`, `flags` |
| `Properties` | `properties`, `properties_info` |
| `WidgetContainer` | widget entity refs (replaces `widgets[]`) |
#### Link
| Component | Data (from `LLink`) |
| --------------- | -------------------------------------------------------------- |
| `LinkEndpoints` | `origin_id`, `origin_slot`, `target_id`, `target_slot`, `type` |
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
| `LinkState` | `_dragging`, `data` |
#### Subgraph (Node Components)
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
| Component | Data |
| ------------------- | ------------------------------------------------------------------------ |
| `SubgraphStructure` | `graphId`, typed interface (input/output names, types, slot entity refs) |
| `SubgraphMeta` | `name`, `description` |
#### Widget
| Component | Data (from `BaseWidget`) |
| ---------------- | ----------------------------------------------------------- |
| `WidgetIdentity` | `name`, `type` (widget type string), parent node entity ref |
| `WidgetValue` | `value`, `options`, `serialize` flags |
| `WidgetLayout` | `computedHeight`, layout size constraints |
#### Slot
| Component | Data (from `SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) |
| ---------------- | ----------------------------------------------------------------------------------- |
| `SlotIdentity` | `name`, `type` (slot type), direction (`input` or `output`), parent node ref, index |
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
#### Reroute
| Component | Data (from `Reroute`) |
| --------------- | --------------------------------- |
| `Position` | `pos` (shared) |
| `RerouteLinks` | `parentId`, input/output link IDs |
| `RerouteVisual` | `color`, badge config |
#### Group
| Component | Data (from `LGraphGroup`) |
| --------------- | ----------------------------------- |
| `Position` | `pos` (shared) |
| `Dimensions` | `size`, `bounding` |
| `GroupMeta` | `title`, `font`, `font_size` |
| `GroupVisual` | `color` |
| `GroupChildren` | child entity refs (nodes, reroutes) |
### World
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).
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-scoped unless explicitly declared shareable.
### Subgraph recursion model
The ECS model preserves recursive nesting without inheritance. A subgraph node
stores `SubgraphStructure.childGraphId`, and the scope registry stores
`childGraphId -> parentGraphId`. This forms a DAG that can represent arbitrary
subgraph depth.
Queries such as "all nodes at depth N" run by traversing the scope registry
from the root, materializing graph IDs at depth `N`, and then filtering entity
queries by `graphScope`.
### Systems (future work)
Systems are pure functions that query the World 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
- **ExecutionSystem** — queries `Execution` + `Connectivity` to determine run order
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
System design is deferred to a future ADR.
### 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
### 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:
- **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.
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).
### Alternatives Considered
- **Refactoring classes in place**: Lower initial cost, but doesn't solve the cross-cutting concern problem. Each new feature still requires modifying multiple god objects.
- **Full rewrite**: Higher risk, blocks feature work during migration. The incremental approach avoids this.
- **Using an existing ECS library** (e.g., bitecs, miniplex): Adds a dependency for a domain that is specific to this project. The graph domain's component shapes don't align well with the dense numeric arrays favored by game-oriented ECS libraries. A lightweight, purpose-built approach is preferred.
## Consequences
### Positive
- 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
- 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`
- 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.
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.
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).
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
## Notes
- 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.

View File

@@ -17,6 +17,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
## Creating a New ADR

View File

@@ -0,0 +1,683 @@
===============================================================================
____ _ _ ____
/ ___|___ __| | ___| |__ __ _ ___ ___ / ___|__ ___ _____ _ __ _ __ ___
| | / _ \ / _` |/ _ \ '_ \ / _` / __|/ _ \ | | / _` \ \ / / _ \ '__| '_ \/ __|
| |__| (_) | (_| | __/ |_) | (_| \__ \ __/ | |__| (_| |\ V / __/ | | | | \__ \
\____\___/ \__,_|\___|_.__/ \__,_|___/\___| \____\__,_| \_/ \___|_| |_| |_|___/
ComfyUI Frontend Architecture Adventure - Complete Walkthrough
===============================================================================
Platform: Web Browser (any modern browser)
Version: 1.0
Author: An Architect Who Has Seen Things
Last Updated: 2026-03-24
Spoilers: YES. This guide contains ALL solutions and ALL endings.
===============================================================================
TABLE OF CONTENTS
===============================================================================
I. Introduction & Controls
II. Game Mechanics
III. Room Guide & Map
IV. Challenge Solutions (SPOILERS)
V. Optimal Route - "The ECS Enlightenment" Speedrun
VI. All Four Endings
VII. Achievements
VIII. Artifacts Checklist
IX. Pro Tips & Secrets
===============================================================================
I. INTRODUCTION & CONTROLS
===============================================================================
Codebase Caverns is an interactive choose-your-own-adventure game that
teaches you the architecture of the ComfyUI frontend codebase. You explore
10 rooms representing different architectural layers, face 9 real engineering
challenges, collect artifacts, and reach one of 4 endings based on your
decisions.
Every challenge in this game is based on REAL architectural problems
documented in the ComfyUI frontend repo. The "correct" answers match the
actual migration strategy being used in production.
CONTROLS:
=========
1, 2, 3 Navigate between rooms (press the number key)
A, B, C Choose a challenge option (press the letter key)
M Toggle the map overlay
Escape Close the map / close ending preview
BUTTONS:
========
Map [M] Opens the room map overlay
Restart Resets the current run (keeps achievements)
Play Again After an ending, starts a new run
Your progress auto-saves to localStorage. Close the tab and come back
later - you'll pick up right where you left off.
===============================================================================
II. GAME MECHANICS
===============================================================================
STATS
=====
You have four stats tracked in the HUD at the top:
Debt [||||||||..] 50 Technical debt. LOWER is better.
Quality [|||.......] 30 Code quality. HIGHER is better.
Morale [||||||....] 60 Team morale. HIGHER is better.
ECS [.........] 0/5 Migration progress. 5 is max.
Each challenge choice modifies these stats. Your final stats determine
which of the 4 endings you get.
CHALLENGES
==========
9 of the 10 rooms contain a one-time challenge - an architectural dilemma
with 2-3 options. Each option has a rating:
[GOOD] Best practice. Matches the real migration strategy.
Usually: Debt down, Quality up, +1 ECS progress.
[OK] Pragmatic but imperfect. Gets the job done.
Mixed stat effects.
[BAD] Tempting but harmful. Short-term gain, long-term pain.
Usually: Debt up or Morale down.
After choosing, you see your result, the recommended answer, and a link
to the real architecture documentation that explains why.
ARTIFACTS
=========
Rooms contain collectible artifacts - key files and concepts from the
codebase. These are auto-collected when you enter a room. They appear
as icons in your Inventory sidebar.
ENDINGS
=======
After resolving all 8 challenges, you get one of 4 endings based on
your accumulated stats. See Section VI for details.
ACHIEVEMENTS
============
Each ending you reach is permanently saved as an achievement badge.
Achievements persist across runs - even after restarting. Click an
unlocked badge to review that ending's screen.
===============================================================================
III. ROOM GUIDE & MAP
===============================================================================
+-------------------+
| ENTRY POINT |
| (src/main.ts) |
+-+--------+------+-+
| | |
+----------+ | +-----------+
| | |
+---v----------+ +-----v--------+ +------v---------+
| COMPONENT | | STORE | | SERVICE |
| GALLERY | | VAULTS | | CORRIDORS |
| [Challenge] | | [Challenge] | | [Challenge] |
+--+------+----+ +--+------+----+ +--------+-------+
| | | | |
| | +----v---+ | +------v-------+
| | | ECS | | | COMPOSABLES |
| | | CHAMB. | | | WORKSHOP |
| | | [Chal] | | | [Challenge] |
| | +---+----+ | +--------------+
| | | +----v------+
| | +----v--+--+ |
| | |SUBGRAPH| RENDERER |
| | | DEPTHS | OVERLOOK |
| | | [Chal] | [Chal] |
| | +--------+----------+
| |
+--v------v----+
| LITEGRAPH |
| ENGINE |
| [Challenge] |
+------+-------+
|
+------v-------+
| COMMAND |
| FORGE |
| [Challenge] |
+--------------+
ROOM DETAILS:
=============
1. THE ENTRY POINT [src/main.ts]
No challenge. No artifacts. Starting room.
Exits: Components (1), Stores (2), Services (3)
2. THE COMPONENT GALLERY [Presentation]
Challenge: The Circular Dependency
Artifacts: GraphView.vue
Exits: Litegraph (1), Command Forge (2), Entry (3)
3. THE STORE VAULTS [State]
Challenge: The Scattered Mutations
Artifacts: widgetValueStore.ts, layoutStore.ts
Exits: ECS (1), Renderer (2), Entry (3)
4. THE SERVICE CORRIDORS [Services]
Challenge: The Migration Question
Artifacts: litegraphService.ts, Extension Migration Guide
Exits: Composables (1), Entry (2)
5. THE LITEGRAPH ENGINE ROOM [Graph Engine]
Challenge: The God Object Dilemma
Artifacts: LGraphCanvas.ts, LGraphNode.ts
Exits: ECS (1), Components (2), Entry (3)
6. THE ECS ARCHITECT'S CHAMBER [ECS]
Challenge: The ID Crossroads
Artifacts: World Registry, Branded Entity IDs
Exits: Subgraph Depths (1), Renderer (2), Entry (3)
7. THE SUBGRAPH DEPTHS [Graph Boundaries]
Challenge: The Widget Promotion Decision
Artifacts: SubgraphStructure, Typed Interface Contracts
Exits: ECS (1), Litegraph (2), Entry (3)
8. THE RENDERER OVERLOOK [Renderer]
Challenge: The Render-Time Mutation
Artifacts: QuadTree Spatial Index, Y.js CRDT Layout
Exits: ECS (1), Entry (2)
9. THE COMPOSABLES WORKSHOP [Composables]
Challenge: The Collaboration Protocol
Artifacts: useCoreCommands.ts
Exits: Stores (1), Entry (2)
10. THE COMMAND FORGE [Commands & Intent]
Challenge: The Mutation Gateway
Artifacts: CommandExecutor, Command Interface
Exits: Components (1), Stores (2), Entry (3)
===============================================================================
IV. CHALLENGE SOLUTIONS (SPOILERS)
===============================================================================
*** WARNING: FULL SOLUTIONS BELOW ***
*** SCROLL PAST SECTION VI IF YOU WANT TO PLAY BLIND ***
.--------------------------------------------------------------------.
| CHALLENGE 1: The Circular Dependency | Room: Components |
|------------------------------------------------------------------ |
| Subgraph extends LGraph, but LGraph creates Subgraph instances. |
| Circular import forces order-dependent barrel exports. |
|------------------------------------------------------------------ |
| |
| >>> A. Composition over inheritance [GOOD] <<< |
| Debt -10, Quality +15, Morale +5, ECS +1 |
| A subgraph IS a graph - just a node with SubgraphStructure. |
| Under graph unification, no class inheritance at all. |
| |
| B. Barrel file reordering [BAD] |
| Debt +10, Quality -5, Morale -5 |
| Band-aid. The coupling remains and will break again. |
| |
| C. Factory injection [OK] |
| Debt -5, Quality +10 |
| Pragmatic fix but classes stay coupled at runtime. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 2: The Scattered Mutations | Room: Stores |
|------------------------------------------------------------------ |
| graph._version++ appears in 19 locations across 7 files. |
| One missed site = silent data loss. |
|------------------------------------------------------------------ |
| |
| >>> A. Centralize into graph.incrementVersion() [GOOD] <<< |
| Debt -15, Quality +15, ECS +1 |
| This is Phase 0a of the real migration plan. |
| 19 sites -> 1 method. Auditable change tracking. |
| |
| B. Add a JavaScript Proxy [OK] |
| Debt +5, Quality +5, Morale -5 |
| Catches mutations but adds opaque runtime overhead. |
| |
| C. Leave it as-is [BAD] |
| Debt +10, Morale +5 |
| "It works, don't touch it" - until it doesn't. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 3: The Migration Question | Room: Services |
|------------------------------------------------------------------ |
| Legacy litegraph works. How to migrate to ECS without breaking |
| production for thousands of users? |
|------------------------------------------------------------------ |
| |
| >>> A. 5-phase incremental plan [GOOD] <<< |
| Quality +15, Morale +10, ECS +1 |
| Foundation -> Types -> Bridge -> Systems -> Legacy Removal. |
| Each phase independently shippable. This is the real plan. |
| |
| B. Big bang rewrite [BAD] |
| Debt -10, Quality +5, Morale -20 |
| Feature freeze + scope creep + burnout = disaster. |
| |
| C. Strangler fig pattern [OK] |
| Quality +10, Morale +5 |
| Solid pattern but lacks clear milestones without a plan. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 4: The God Object Dilemma | Room: Litegraph |
|------------------------------------------------------------------ |
| LGraphCanvas: ~9,100 lines. LGraphNode: ~4,300 lines. |
| God objects mixing rendering, serialization, connectivity, etc. |
|------------------------------------------------------------------ |
| |
| A. Rewrite from scratch [BAD] |
| Debt -20, Quality +5, Morale -25 |
| Heroic rewrite stalls at month three. Team burns out. |
| |
| >>> B. Extract incrementally [GOOD] <<< |
| Debt -10, Quality +15, Morale +5, ECS +1 |
| Position -> Connectivity -> Rendering. Small testable PRs. |
| This matches the actual migration strategy. |
| |
| C. Add a facade layer [OK] |
| Debt +5, Quality +5, Morale +10 |
| Nicer API but complexity lives behind the facade. |
| |
| NOTE: This is the only challenge where A is NOT the best answer! |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 5: The ID Crossroads | Room: ECS |
|------------------------------------------------------------------ |
| NodeId is number | string. Nothing prevents passing a LinkId |
| where a NodeId is expected. Six entity kinds share one ID space. |
|------------------------------------------------------------------ |
| |
| >>> A. Branded types with cast helpers [GOOD] <<< |
| Debt -15, Quality +20, ECS +1 |
| type NodeEntityId = number & { __brand: 'NodeEntityId' } |
| Compile-time safety, zero runtime cost. Phase 1a. |
| |
| B. String prefixes at runtime [OK] |
| Debt +5, Quality +5, Morale -5 |
| "node:42" - parsing overhead everywhere. |
| |
| C. Keep plain numbers [BAD] |
| Debt +15, Quality -5 |
| "Just be careful" - someone WILL pass the wrong ID. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 6: The Widget Promotion Decision | Room: Subgraph |
|------------------------------------------------------------------ |
| A user promotes a widget from inside a subgraph to the parent. |
| Today this needs PromotionStore + ViewManager + PromotedWidgetView |
| — a parallel state system. Two ECS candidates. |
|------------------------------------------------------------------ |
| |
| >>> A. Connections-only: promotion = typed input [GOOD] <<< |
| Debt -15, Quality +15, Morale +5, ECS +1 |
| Promotion = adding an interface input. Type->widget mapping |
| creates the widget automatically. Eliminates PromotionStore, |
| ViewManager, and PromotedWidgetView entirely. |
| |
| B. Simplified component promotion [OK] |
| Debt -5, Quality +10, Morale +5 |
| WidgetPromotion component on widget entities. Removes |
| ViewManager but keeps promotion as a distinct concept. |
| Shared subgraph instance ambiguity remains. |
| |
| C. Keep the current three-layer system [BAD] |
| Debt +10, Quality -5, Morale -5 |
| The parallel state system persists indefinitely. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 7: The Render-Time Mutation | Room: Renderer |
|------------------------------------------------------------------ |
| drawNode() calls _setConcreteSlots() and arrange() during the |
| render pass. Draw order affects layout. Classic mutation-in- |
| render bug. |
|------------------------------------------------------------------ |
| |
| >>> A. Separate update and render phases [GOOD] <<< |
| Debt -15, Quality +15, ECS +1 |
| Input -> Update (layout) -> Render (read-only). |
| Matches the ECS system pipeline design. |
| |
| B. Dirty flags and deferred render [OK] |
| Debt -5, Quality +5, Morale +5 |
| Reduces symptoms but render pass can still mutate. |
| |
| NOTE: Only 2 options here. Both are reasonable; A is optimal. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 8: The Collaboration Protocol | Room: Composables |
|------------------------------------------------------------------ |
| Multiple users want to edit the same workflow simultaneously. |
| layoutStore already extracts position data. How to sync? |
|------------------------------------------------------------------ |
| |
| >>> A. Y.js CRDTs [GOOD] <<< |
| Debt -10, Quality +15, Morale +10 |
| Conflict-free replicated data types. Already proven. |
| This is what the real layoutStore uses. |
| |
| B. Polling-based sync [BAD] |
| Debt +10, Quality -5, Morale -5 |
| Flickering, lag, silent data loss. Support nightmare. |
| |
| C. Skip collaboration for now [OK] |
| Morale +5 |
| Pragmatic delay but cloud team won't be happy. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| CHALLENGE 9: The Mutation Gateway | Room: Command Forge |
|------------------------------------------------------------------ |
| The World's imperative API (world.setComponent()) vs. the command |
| pattern requirement from ADR 0003. How should external callers |
| mutate the World? |
|------------------------------------------------------------------ |
| |
| >>> A. Commands as intent; systems as handlers; World as store <<< |
| Debt -10, Quality +15, Morale +5, ECS +1 [GOOD] |
| Caller -> Command -> System -> World -> Y.js. Commands are |
| serializable. ADR 0003 and ADR 0008 are complementary. |
| |
| B. Make World.setComponent() itself serializable [OK] |
| Debt +5, Quality +5, Morale -5 |
| Conflates store with command layer. Batch ops become noisy. |
| |
| C. Skip commands - let callers mutate directly [BAD] |
| Debt +15, Quality -10 |
| No undo/redo, no replay, no CRDT sync, no audit trail. |
'--------------------------------------------------------------------'
===============================================================================
V. OPTIMAL ROUTE - "THE ECS ENLIGHTENMENT" SPEEDRUN
===============================================================================
This route hits all 8 challenges picking the GOOD answer, collecting
all 13 artifacts, visiting all 10 rooms. Order matters for efficiency
(fewest key presses).
Starting stats: Debt 50, Quality 30, Morale 60, ECS 0/5
ENTRY POINT
Press 1 -> Component Gallery
COMPONENT GALLERY
Challenge: The Circular Dependency -> Press A (Composition)
[Debt 40, Quality 45, Morale 65, ECS 1/5]
Press 2 -> Command Forge
THE COMMAND FORGE
Challenge: The Mutation Gateway -> Press A (Commands as intent)
[Debt 30, Quality 60, Morale 70, ECS 2/5]
Press 2 -> Store Vaults
STORE VAULTS
Challenge: The Scattered Mutations -> Press A (Centralize)
[Debt 15, Quality 75, Morale 70, ECS 3/5]
Press 1 -> ECS Chamber
ECS ARCHITECT'S CHAMBER
Challenge: The ID Crossroads -> Press A (Branded types)
[Debt 0, Quality 95, Morale 70, ECS 4/5]
Press 1 -> Subgraph Depths
SUBGRAPH DEPTHS
Challenge: The Widget Promotion Decision -> Press A (Connections-only)
[Debt 0, Quality 100, Morale 75, ECS 5/5]
Press 1 -> ECS Chamber
Press 2 -> Renderer
RENDERER OVERLOOK
Challenge: The Render-Time Mutation -> Press A (Separate phases)
[Debt 0, Quality 100, Morale 75, ECS 5/5]
Press 2 -> Entry Point
ENTRY POINT
Press 3 -> Services
SERVICE CORRIDORS
Challenge: The Migration Question -> Press A (5-phase plan)
[Debt 0, Quality 100, Morale 85, ECS 5/5]
Press 1 -> Composables
COMPOSABLES WORKSHOP
Challenge: The Collaboration Protocol -> Press A (Y.js CRDTs)
[Debt 0, Quality 100, Morale 95, ECS 5/5]
Press 2 -> Entry Point
ENTRY POINT
Press 1 -> Components
Press 1 -> Litegraph
LITEGRAPH ENGINE ROOM
Challenge: The God Object Dilemma -> Press B (Extract incrementally)
[Debt 0, Quality 100, Morale 100, ECS 5/5]
FINAL STATS: Debt 0 | Quality 100 | Morale 100 | ECS 5/5
*** ENDING: THE ECS ENLIGHTENMENT ***
Total key presses: 28 (including challenge answers)
Rooms visited: 10/10
Artifacts: 16/16
Challenges: 9/9 correct
===============================================================================
VI. ALL FOUR ENDINGS
===============================================================================
Endings are checked in order. First match wins.
.--------------------------------------------------------------------.
| |
| ENDING 1: THE ECS ENLIGHTENMENT [BEST] |
| |
| Requirements: Debt < 25 AND Quality >= 75 AND Morale >= 60 |
| |
| "The World registry hums with clean data. Node removal: |
| 30 lines instead of 107. Serialization: one system instead |
| of six scattered methods. Branded IDs catch bugs at compile |
| time. Y.js CRDTs enable real-time collaboration. The team |
| ships features faster than ever." |
| |
| HOW TO GET IT: Pick ALL good answers. Hard to miss if you |
| read the hints carefully. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| |
| ENDING 2: THE CLEAN ARCHITECTURE [GOOD] |
| |
| Requirements: Debt < 40 AND Quality >= 50 |
| |
| "The migration completes on schedule. Systems hum along, |
| the ECS World holds most entity state, and the worst god |
| objects have been tamed." |
| |
| HOW TO GET IT: Pick mostly good answers, 1-2 OK answers. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| |
| ENDING 3: THE ETERNAL REFACTOR [MEH] |
| |
| Requirements: Debt < 70 |
| |
| "The migration... continues. Every sprint has a 'cleanup' |
| ticket that never quite closes." |
| |
| HOW TO GET IT: Mix of OK and BAD answers. The "safe" middle. |
'--------------------------------------------------------------------'
.--------------------------------------------------------------------.
| |
| ENDING 4: THE SPAGHETTI SINGULARITY [WORST] |
| |
| Requirements: Debt >= 70 (catch-all) |
| |
| "The god objects grew sentient. LGraphCanvas hit 12,000 lines |
| and developed a circular dependency with itself." |
| |
| HOW TO GET IT: Pick all BAD answers. You have to try. |
| Starting debt is 50, so you need +20 from bad choices. |
'--------------------------------------------------------------------'
===============================================================================
VII. ACHIEVEMENTS
===============================================================================
Achievements are permanently saved across runs. You need 4 playthroughs
(minimum) to unlock all endings, since each run can only reach one.
[x] The ECS Enlightenment - All good answers
[x] The Clean Architecture - Mostly good, few OK
[x] The Eternal Refactor - Mix of OK and bad
[x] The Spaghetti Singularity - Maximize debt (see pro tip below)
Click any unlocked achievement badge in the Endings sidebar panel
to review that ending's screen without resetting your current game.
PRO TIP: "The Spaghetti Singularity" requires Debt >= 70. This is
TRICKY because some "bad" answers actually LOWER debt! Rewrites
(Litegraph A: Debt -20) and big bang rewrites (Services B: Debt -10)
reduce debt short-term even though they tank morale.
To hit Debt >= 70 you must pick options that ADD debt or leave it
alone. Here's the proven path (starting at Debt 50):
Components: B (Barrel file reordering) Debt +10 -> 60
Command Forge: C (Skip commands) Debt +15 -> 75
Stores: C (Leave it as-is) Debt +10 -> 85
Services: C (Strangler fig) Debt +0 -> 85
Litegraph: C (Add a facade) Debt +5 -> 90
ECS: C (Keep plain numbers) Debt +15 -> 100
Subgraph: C (Keep three-layer system) Debt +10 -> 100
Renderer: B (Dirty flags) Debt -5 -> 95
Composables: B (Polling-based sync) Debt +10 -> 100
Final: Debt 100 / Quality 10 / Morale 50 -> SPAGHETTI SINGULARITY
WARNING: Picking "all bad-rated answers" does NOT work! The bad
answers for Litegraph (A: Rewrite, Debt -20) and Services (B: Big
bang, Debt -10) have negative debt effects that pull you back
under 70.
===============================================================================
VIII. ARTIFACTS CHECKLIST
===============================================================================
Room | Artifact | Type
==================|============================|==================
Component Gallery | GraphView.vue | Component
Store Vaults | widgetValueStore.ts | Proto-ECS Store
Store Vaults | layoutStore.ts | Proto-ECS Store
Service Corridors | litegraphService.ts | Service
Service Corridors | Extension Migration Guide | Design Pattern
Litegraph Engine | LGraphCanvas.ts | God Object
Litegraph Engine | LGraphNode.ts | God Object
ECS Chamber | World Registry | ECS Core
ECS Chamber | Branded Entity IDs | Type Safety
Subgraph Depths | SubgraphStructure | ECS Component
Subgraph Depths | Typed Interface Contracts | Design Pattern
Renderer Overlook | QuadTree Spatial Index | Data Structure
Renderer Overlook | Y.js CRDT Layout | Collaboration
Composables | useCoreCommands.ts | Composable
Command Forge | CommandExecutor | ECS Core
Command Forge | Command Interface | Design Pattern
Total: 16 artifacts across 9 rooms.
Entry Point has no artifacts.
===============================================================================
IX. PRO TIPS & SECRETS
===============================================================================
* Your game auto-saves after every room change and challenge. Close
the tab and come back anytime - you won't lose progress.
* The Restart button in the HUD resets your run but KEEPS your
achievement badges. Use it to go for a different ending.
* Every code reference in the room descriptions is a clickable link
to the actual file on GitHub. Open them in new tabs to read the
real code while you play.
* After each challenge, the "Read more" link takes you to the
architecture documentation that explains the real engineering
rationale behind the recommended answer.
* The map overlay (press M) shows challenge badges:
[?] = challenge available but not yet attempted
[v] = challenge completed
* Room navigation preloads images for adjacent rooms, so transitions
should be instant after the first visit.
* The Command Forge (formerly the Side Panel) teaches the Command
Pattern - how commands relate to systems and the World. Its challenge
covers the architectural layering from ADR 0003 and ADR 0008.
* The ECS Migration Progress stat maxes at 5, matching the 5 phases
of the real migration plan. But 9 challenges can give +1 each
(8 of the 9 GOOD answers grant +1 ECS). The Services challenge
("5-phase plan") gives +1 ECS but no debt reduction - it's pure
planning, not implementation.
* There are between 2-3 choices per challenge, giving
3*3*3*3*3*3*2*3*3 = 13,122 possible playthroughs. But only 4
distinct endings. Most paths lead to "The Clean Architecture"
or "The Eternal Refactor."
* If you want to learn the ComfyUI frontend architecture for real,
the recommended reading order matches the optimal speedrun route:
1. src/main.ts (entry point)
2. src/views/GraphView.vue (main canvas)
3. src/stores/ (state management)
4. src/ecs/ (the future)
5. docs/architecture/ecs-world-command-api.md (command layer)
6. src/renderer/core/ (canvas pipeline)
7. docs/architecture/ecs-migration-plan.md (the plan)
8. src/composables/ (Vue logic hooks)
9. src/lib/litegraph/src/ (the legacy engine)
* The pixel art images were generated using the Z-Image Turbo
pipeline on the same ComfyUI that this frontend controls.
Meta, isn't it?
===============================================================================
This document Copyright (c) 2026 A Concerned Architect
ComfyUI is maintained by Comfy-Org: https://github.com/Comfy-Org
"In a world of god objects, be an entity-component-system."
___
| |
___| |___
| |
| COMFY UI |
| FRONTEND |
|___________|
| | | | | | |
| | | | | | |
_| | | | | | |_
|_______________|
GG. GIT GUD.
===============================================================================

View File

@@ -0,0 +1,26 @@
{
"meta": {
"style": "Pixel art badge/medal icon, 128x128, dark background, achievement unlock style",
"usage": "Each key matches an ending ID. Shown in achievements panel when that ending has been reached.",
"model": "Z-Image Turbo (no LoRA)",
"resolution": "128x128"
},
"achievements": {
"great": {
"title": "The ECS Enlightenment",
"prompt": "Pixel art achievement badge of a radiant crystal temple with clean geometric architecture, bright green and gold triumphant glow, laurel wreath border, dark background"
},
"good": {
"title": "The Clean Architecture",
"prompt": "Pixel art achievement badge of a solid fortress with neat organized blocks, blue and silver steady glow, star emblem, dark background"
},
"mediocre": {
"title": "The Eternal Refactor",
"prompt": "Pixel art achievement badge of an hourglass with sand still flowing endlessly, amber and grey weary glow, circular border, dark background"
},
"disaster": {
"title": "The Spaghetti Singularity",
"prompt": "Pixel art achievement badge of a tangled mass of spaghetti code wires collapsing into a black hole, red and purple chaotic glow, cracked border, dark background"
}
}
}

View File

@@ -0,0 +1,114 @@
{
"meta": {
"style": "Pixel art icon, 128x128, dark background, game UI button icon style, clean readable silhouette",
"usage": "Each key is {room}-{choiceKey lowercase}. Used in challenge choice buttons in adventure.html.",
"model": "Z-Image Turbo (no LoRA)",
"resolution": "128x128"
},
"choices": {
"components-a": {
"label": "Composition over inheritance",
"prompt": "Pixel art icon of puzzle pieces snapping together cleanly, green glow, dark background, game UI icon"
},
"components-b": {
"label": "Barrel file reordering",
"prompt": "Pixel art icon of a stack of files being shuffled with arrows, amber warning glow, dark background, game UI icon"
},
"components-c": {
"label": "Factory injection",
"prompt": "Pixel art icon of a factory building with a syringe injecting into it, blue mechanical glow, dark background, game UI icon"
},
"stores-a": {
"label": "Centralize into graph.incrementVersion()",
"prompt": "Pixel art icon of scattered dots converging into a single glowing funnel point, green glow, dark background, game UI icon"
},
"stores-b": {
"label": "Add a JavaScript Proxy",
"prompt": "Pixel art icon of a shield proxy intercepting arrows mid-flight, amber translucent glow, dark background, game UI icon"
},
"stores-c": {
"label": "Leave it as-is",
"prompt": "Pixel art icon of a shrug gesture with cobwebs on old machinery, grey muted glow, dark background, game UI icon"
},
"services-a": {
"label": "5-phase incremental plan",
"prompt": "Pixel art icon of five stepping stones ascending in a staircase with checkmarks, green glow, dark background, game UI icon"
},
"services-b": {
"label": "Big bang rewrite",
"prompt": "Pixel art icon of a dynamite stick with lit fuse and explosion sparks, red danger glow, dark background, game UI icon"
},
"services-c": {
"label": "Strangler fig pattern",
"prompt": "Pixel art icon of vines growing around and enveloping an old tree trunk, green and brown organic glow, dark background, game UI icon"
},
"litegraph-a": {
"label": "Rewrite from scratch",
"prompt": "Pixel art icon of a wrecking ball demolishing a building into rubble, red destructive glow, dark background, game UI icon"
},
"litegraph-b": {
"label": "Extract incrementally",
"prompt": "Pixel art icon of surgical tweezers carefully extracting a glowing module from a larger block, green precise glow, dark background, game UI icon"
},
"litegraph-c": {
"label": "Add a facade layer",
"prompt": "Pixel art icon of a decorative mask covering a cracked wall, yellow cosmetic glow, dark background, game UI icon"
},
"ecs-a": {
"label": "Branded types with cast helpers",
"prompt": "Pixel art icon of ID badges with distinct colored stamps and a compiler checkmark, green type-safe glow, dark background, game UI icon"
},
"ecs-b": {
"label": "String prefixes at runtime",
"prompt": "Pixel art icon of text labels being parsed with a magnifying glass at runtime, amber slow glow, dark background, game UI icon"
},
"ecs-c": {
"label": "Keep plain numbers",
"prompt": "Pixel art icon of bare numbers floating unprotected with a question mark, red risky glow, dark background, game UI icon"
},
"renderer-a": {
"label": "Separate update and render phases",
"prompt": "Pixel art icon of two clean pipeline stages labeled U and R with an arrow between them, green orderly glow, dark background, game UI icon"
},
"renderer-b": {
"label": "Dirty flags and deferred render",
"prompt": "Pixel art icon of a flag with a smudge mark and a clock showing delay, amber patch glow, dark background, game UI icon"
},
"composables-a": {
"label": "Y.js CRDTs",
"prompt": "Pixel art icon of two documents merging seamlessly with sync arrows and no conflicts, green collaboration glow, dark background, game UI icon"
},
"composables-b": {
"label": "Polling-based sync",
"prompt": "Pixel art icon of a clock with circular refresh arrows and flickering signal, red laggy glow, dark background, game UI icon"
},
"composables-c": {
"label": "Skip collaboration for now",
"prompt": "Pixel art icon of a single person at a desk with a pause symbol, grey neutral glow, dark background, game UI icon"
},
"subgraph-a": {
"label": "Connections-only: promotion = adding a typed input",
"prompt": "Pixel art icon of a function signature with typed input slots and a green checkmark, clean minimal glow, dark background, game UI icon"
},
"subgraph-b": {
"label": "Simplified component promotion",
"prompt": "Pixel art icon of a widget being lifted up with a promotion arrow and a component badge, amber glow, dark background, game UI icon"
},
"subgraph-c": {
"label": "Keep the current three-layer system",
"prompt": "Pixel art icon of three stacked translucent layers with proxy shadows underneath, red complex glow, dark background, game UI icon"
},
"sidepanel-a": {
"label": "Commands as intent; systems as handlers; World as store",
"prompt": "Pixel art icon of a layered architectural diagram with arrows flowing top-to-bottom through five labeled tiers, green glow, dark background, game UI icon"
},
"sidepanel-b": {
"label": "Make World.setComponent() itself serializable",
"prompt": "Pixel art icon of a database with every cell being logged into a scroll, amber overflow glow, dark background, game UI icon"
},
"sidepanel-c": {
"label": "Skip commands — let callers mutate directly",
"prompt": "Pixel art icon of multiple hands reaching into a glowing orb simultaneously causing cracks, red chaos glow, dark background, game UI icon"
}
}
}

View File

@@ -0,0 +1,90 @@
{
"meta": {
"style": "Pixel art icon on transparent black background, 128x128, clean edges, glowing accent color, game inventory item style",
"usage": "Each key is an artifact ID used in adventure.html. Generate one icon per artifact.",
"model": "Z-Image Turbo (no LoRA)",
"resolution": "128x128"
},
"artifacts": {
"graphview": {
"name": "GraphView.vue",
"type": "Component",
"prompt": "Pixel art icon of a glowing canvas frame with connected nodes and wires inside, blue accent glow, dark background, game inventory item"
},
"widgetvaluestore": {
"name": "widgetValueStore.ts",
"type": "Proto-ECS Store",
"prompt": "Pixel art icon of a vault door with a glowing slider widget embossed on it, purple and gold accents, dark background, game inventory item"
},
"layoutstore": {
"name": "layoutStore.ts",
"type": "Proto-ECS Store",
"prompt": "Pixel art icon of a grid blueprint with glowing position markers, purple accent lines, dark background, game inventory item"
},
"litegraphservice": {
"name": "litegraphService.ts",
"type": "Service",
"prompt": "Pixel art icon of a gear with a graph node symbol in the center, copper and blue metallic glow, dark background, game inventory item"
},
"lgraphcanvas": {
"name": "LGraphCanvas.ts",
"type": "God Object",
"prompt": "Pixel art icon of a massive cracked monolith radiating red warning light, labeled 9100, ominous dark background, game inventory item"
},
"lgraphnode": {
"name": "LGraphNode.ts",
"type": "God Object",
"prompt": "Pixel art icon of an oversized cube with tangled wires bursting from every face, red and amber glow, dark background, game inventory item"
},
"world-registry": {
"name": "World Registry",
"type": "ECS Core",
"prompt": "Pixel art icon of a glowing crystalline orb containing tiny entity symbols, bright blue and white aura, dark background, game inventory item"
},
"branded-ids": {
"name": "Branded Entity IDs",
"type": "Type Safety",
"prompt": "Pixel art icon of a set of ID cards with distinct colored borders and brand stamps, green checkmark glow, dark background, game inventory item"
},
"quadtree": {
"name": "QuadTree Spatial Index",
"type": "Data Structure",
"prompt": "Pixel art icon of a square recursively divided into four quadrants with glowing dots at intersections, teal accent, dark background, game inventory item"
},
"yjs-crdt": {
"name": "Y.js CRDT Layout",
"type": "Collaboration",
"prompt": "Pixel art icon of two overlapping document layers merging with sync arrows, purple and green glow, dark background, game inventory item"
},
"usecorecommands": {
"name": "useCoreCommands.ts",
"type": "Composable",
"prompt": "Pixel art icon of a hook tool with keyboard key symbols orbiting it, yellow and blue glow, dark background, game inventory item"
},
"subgraph-structure": {
"name": "SubgraphStructure",
"type": "ECS Component",
"prompt": "Pixel art icon of nested rectangular frames inside each other like Russian dolls with glowing typed connections at each boundary, purple and teal accent, dark background, game inventory item"
},
"typed-contracts": {
"name": "Typed Interface Contracts",
"type": "Architecture",
"prompt": "Pixel art icon of a sealed scroll with a glowing typed signature stamp and interface brackets, gold and blue accent, dark background, game inventory item"
},
"command-executor": {
"name": "CommandExecutor",
"type": "ECS Core",
"prompt": "Pixel art icon of a glowing anvil with a gear and execute arrow symbol, blue-purple forge glow, dark background, game inventory item"
},
"command-interface": {
"name": "Command Interface",
"type": "Design Pattern",
"prompt": "Pixel art icon of a sealed scroll with a type discriminator tag and execute method seal, blue glow, dark background, game inventory item"
},
"extension-migration": {
"name": "Extension Migration Guide",
"type": "Design Pattern",
"prompt": "Pixel art icon of a scroll with legacy code on left transforming via arrow to ECS code on right, green transition glow, dark background, game inventory item"
}
}
}

View File

@@ -0,0 +1,61 @@
{
"meta": {
"style": "Pixel art, 16:9 aspect ratio, dark moody palette with glowing accent lighting",
"usage": "Each key corresponds to a room ID in adventure.html. Generate images with generate-images.py.",
"model": "Z-Image Turbo (no LoRA)",
"resolution": "1152x640",
"generated": "2026-03-24"
},
"rooms": {
"entry": {
"title": "The Entry Point",
"prompt": "Pixel art of a glowing terminal in a vast dark server room, Vue.js and TypeScript logos floating as holographic projections, three corridors branching ahead lit by blue, green, and purple lights",
"path": "images/entry.png"
},
"components": {
"title": "The Component Gallery",
"prompt": "Pixel art gallery hall with framed Vue component cards hung on stone walls, a massive canvas painting labeled 'GraphView' in the center, smaller panels flanking either side, warm torchlight",
"path": "images/components.png"
},
"stores": {
"title": "The Store Vaults",
"prompt": "Pixel art underground vault with 60 glowing vault doors lining the walls, three doors in front glow brightest (labeled widget, layout, promotion), a Pinia pineapple emblem etched in stone above",
"path": "images/stores.png"
},
"services": {
"title": "The Service Corridors",
"prompt": "Pixel art clean corridors with labeled pipes connecting rooms overhead, data flowing as glowing particles through transparent tubes, service names etched on brass plaques",
"path": "images/services.png"
},
"litegraph": {
"title": "The Litegraph Engine Room",
"prompt": "Pixel art dark engine room with three massive monolith machines labeled 9100, 4300, and 3100 lines of code, warning lights flashing amber, tangled wires and cables everywhere",
"path": "images/litegraph.png"
},
"ecs": {
"title": "The ECS Architect's Chamber",
"prompt": "Pixel art architect's drafting room with blueprints pinned to walls showing entity-component diagrams, a glowing World orb floating in the center, branded ID cards scattered across the desk",
"path": "images/ecs.png"
},
"subgraph": {
"title": "The Subgraph Depths",
"prompt": "Pixel art recursive fractal chamber where identical rooms nest inside each other like Russian dolls, typed contract scrolls float at each boundary doorway, a DAG tree diagram glows on the ceiling",
"path": "images/subgraph.png"
},
"renderer": {
"title": "The Renderer Overlook",
"prompt": "Pixel art observation deck overlooking a vast canvas being painted by precise robotic arms, Y.js CRDT symbols floating in the air, a QuadTree grid visible on the floor below",
"path": "images/renderer.png"
},
"composables": {
"title": "The Composables Workshop",
"prompt": "Pixel art workshop with hooks hanging from a pegboard wall, each labeled (useCoreCommands, useCanvasDrop, etc.), workbenches for auth, canvas, and queue domains, cozy lantern light",
"path": "images/composables.png"
},
"sidepanel": {
"title": "The Command Forge",
"prompt": "Pixel art anvil forge where glowing command scrolls are being hammered into structured objects, a layered diagram on the wall showing five architectural tiers connected by arrows, blue and purple forge light, dark background",
"path": "images/sidepanel.png"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
# Appendix: A Critical Analysis of the Architecture Documents
_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._
---
## I. On the Accuracy of Self-Diagnosis
Verification snapshot: code references were checked against commit
`e51982ee1`.
The architecture documents present themselves as a clinical intake — a patient arriving with a detailed account of its own suffering. One is naturally suspicious of such thoroughness; the neurotic who describes his symptoms too precisely is often defending against a deeper, unnamed wound. And yet, upon examination, we find the self-report to be remarkably honest.
The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines — the ego of the system, attempting to mediate between the inner world of data and the outer world of the user, and collapsing under the weight of that mediation. `LGraphNode` at 4,285 lines and `LGraph` at 3,114 confirm that these are not exaggerations born of self-pity but accurate measurements of genuine hypertrophy.
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.
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
confronted its shadow material rather than merely projecting it.
## II. On the Errors: Small Falsifications of Memory
No self-report is without its distortions. The unconscious edits memory, not out of malice, but because the psyche organizes experience around meaning rather than fact.
Five such distortions were identified:
**The Misnamed Method.** The documents claim `toJSON()` exists at `LGraphNode.ts:1033`. In truth, line 1033 holds `toString()`. This is a telling substitution — the psyche conflates the act of converting oneself to a string representation (how one _appears_) with the act of serializing oneself for transmission (how one _persists_). These are different operations, but the patient experiences them as the same anxiety.
**The Renamed Function.** `execute()` is cited at line 1418. The actual method is `doExecute()` at line 1411. The prefix "do" carries weight — it is the difference between the intention and the act, between the persona and the behavior. The documents elide this distinction, preferring the cleaner, more archetypal name.
**The Understated Magnitude.** The documents claim `LGraphNode` has ~539 method/property definitions. A systematic count yields approximately 848. The psyche has minimized the extent of the fragmentation — a common defense. One does not wish to know the full measure of one's own complexity.
**The Compressed History.** `LGraph.configure()` is described as ~180 lines. It spans approximately 247. The method has grown since it was last measured, as living things do, but the documents preserve an earlier, smaller self-image. Time has passed; the patient has not updated its intake form.
**The Phantom Method.** The proto-ECS analysis references `resolveDeepest()` on the `PromotedWidgetViewManager`. This method does not exist. The class uses `reconcile()` and `getOrCreate()` — less evocative names for what is, symbolically, the same operation: reaching through layers of abstraction to find the authentic, concrete thing beneath. The documents have invented a name that better captures the _meaning_ of the operation than the names the code actually uses. This is poetry, not documentation.
These errors are minor in isolation. Collectively, they suggest a pattern familiar to the analyst: the documents describe the system not quite as it _is_, but as it _understands itself to be_. The gap between these is small — but it is precisely in such gaps that the interesting material lives.
## III. On the Dream of the World: The ECS Target as Individuation Fantasy
The target architecture documents read as a vision of wholeness. Where the current system is fragmented — god-objects carrying too many responsibilities, circular dependencies binding parent to child in mutual entanglement, scattered side effects erupting unpredictably — the ECS future promises integration. A single World. Pure systems. Branded identities. Unidirectional flow.
This is the individuation dream: the fragmented psyche imagines itself unified, each complex (component) named and contained, each archetypal function (system) operating in its proper domain, the Self (World) holding all of it in coherent relation.
It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality.
### The Line-Count Comparisons
The lifecycle scenarios compare current implementations against projected ECS equivalents:
| Operation | Current | Projected ECS |
| ------------- | ---------- | ------------- |
| Node removal | ~107 lines | ~30 lines |
| Pack subgraph | ~200 lines | ~50 lines |
| Copy/paste | ~300 lines | ~60 lines |
These ratios — roughly 4:1 — are the ratios of a daydream. They may prove accurate. But they are estimates for code that does not yet exist, and the unconscious is generous with its projections of future ease. Real implementations accumulate weight as they encounter the particularities that theory elides: validation callbacks, error recovery, extension hooks, the sheer cussedness of edge cases that only reveal themselves in production.
The documents would benefit from acknowledging this uncertainty explicitly. "We expect" is more honest than "it will be."
### The Vanishing Callbacks
The current system maintains an elaborate network of lifecycle callbacks: `onConnectInput()`, `onConnectOutput()`, `onConnectionsChange()`, `onRemoved()`, `onAdded()`. These are the system's relationships — its contracts with the extensions that depend upon it.
The ECS scenarios show these callbacks disappearing. "No callbacks — systems query World after deserialization." This is presented as simplification, and structurally it is. But psychologically, it is the most dangerous moment in any transformation: the point at which the individuating self believes it can shed its relationships without consequence.
Extensions rely on these callbacks. They are the public API through which the outer world interacts with the system's inner life. The documents do not discuss how this API would be preserved, adapted, or replaced. This is not a minor omission — it is the repression of the system's most anxiety-producing constraint.
### The Atomicity Wish
The ECS scenarios describe operations as "atomic" — pack subgraph, unpack subgraph, node removal, all happening as unified state transitions with no intermediate inconsistency.
This is the wish for a moment of transformation without vulnerability. In reality, unless the World implements transactional semantics, a failure mid-operation would leave the same inconsistent state the current system risks. The existing `beforeChange()`/`afterChange()` pattern, for all its scattered invocations, at least provides undo snapshots. The documents do not discuss what replaces this guarantee.
The desire for atomicity is healthy. The assumption that it comes free with the architecture is not.
### The CRDT Question
The `LayoutStore` is correctly identified as "the most architecturally advanced extraction." It wraps Y.js CRDTs — a technology chosen for collaborative editing, as noted in ADR 0003.
But the documents do not address the tension between Y.js and a pure ECS World. Would the World contain Y.js documents? Would it replace them? Would the Position component be a CRDT, a plain object, or a proxy that reads from one? This is not an implementation detail — it is a fundamental architectural question about whether the system's two most sophisticated subsystems (collaboration and ECS) can coexist or must be reconciled.
The silence on this point is the silence of a psyche that has not yet confronted a genuine dilemma.
## IV. On the Keying Strategies: Identity and Its Discontents
The proto-ECS analysis catalogs five different keying strategies across five stores and presents this multiplicity as pathological. There is truth in this — the absence of a unified identity system does create real confusion and real bugs.
But one must be careful not to mistake diversity for disorder. Some of these composite keys — `"${nodeId}:${widgetName}"`, for instance — reflect a genuine structural reality: a widget is identified by its relationship to a node and its name within that node. A branded `WidgetEntityId` would replace this composite key with a synthetic integer, gaining cross-kind safety but losing the self-documenting quality of the composite.
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.
## 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.
A Subgraph is a Graph that lives inside a Node that lives inside a Graph. It is the child that contains the parent's structure — the recursive self-reference that gives the system its power and its pathology simultaneously. The barrel export comment at `litegraph.ts:15` is a symptom, yes, but it is also an honest acknowledgment of a genuine structural paradox.
The ECS target resolves this by flattening: "Entities are just IDs. No inheritance hierarchy." This is a valid architectural choice. But it is worth noting that the current circular structure _accurately models the domain_. A subgraph _is_ a graph. The inheritance relationship is not arbitrary — it reflects a real isomorphism.
The ECS approach replaces structural modeling with data modeling. This eliminates the circular dependency but requires the system to reconstruct the "a subgraph is a graph" relationship through component composition rather than inheritance. The documents assume this is straightforward. It may not be — the recursive case (subgraphs containing subgraphs) will test whether flat entity composition can express what hierarchical inheritance expresses naturally.
## VI. On the Migration Bridge: The Transitional Object
The migration bridge described in the target architecture is perhaps the most psychologically astute element of the entire proposal. It acknowledges that transformation cannot happen all at once — that the old structures must coexist with the new until the new have proven themselves capable of bearing the load.
The three-phase sequence (bridge reads from class and writes to World; new features build on World directly; bridge removed) is the sequence of every successful therapeutic process: first, the new understanding runs alongside the old patterns; then, new behavior begins to emerge from the new understanding; finally, the old patterns are released because they are no longer needed, not because they have been forcibly suppressed.
This is sound. The documents would benefit from being equally realistic about the _duration_ of the bridge phase. In a system with this many extensions, this much surface area, and this much organic complexity, the bridge may persist for a very long time. This is not failure — it is the natural pace of genuine transformation.
## VII. Summary of Findings
### 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()` |
### Analytical Gaps
1. **Extension API continuity** is the largest unaddressed risk in the migration.
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."
### What the Documents Do Well
The problem diagnosis is grounded, specific, and verified. The proto-ECS analysis correctly identifies organic convergence toward ECS patterns. The lifecycle scenarios effectively communicate the structural simplification that ECS enables. The change-tracker document is accurate and immediately useful.
These are the documents of a system that has looked at itself honestly — which is, as any analyst will tell you, the necessary precondition for change.

View File

@@ -0,0 +1,744 @@
# ECS Lifecycle Scenarios
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.
## 1. Node Removal
### Current Flow
`LGraph.remove(node)` — 107 lines, touches 6+ entity types and 4+ external systems:
```mermaid
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant L as LLink
participant R as Reroute
participant C as LGraphCanvas
participant LS as LayoutStore
Caller->>G: remove(node)
G->>G: beforeChange() [undo checkpoint]
loop each input slot
G->>N: disconnectInput(i)
N->>L: link.disconnect(network)
L->>G: _links.delete(linkId)
L->>R: cleanup orphaned reroutes
N->>LS: layoutMutations.removeLink()
N->>G: _version++
end
loop each output slot
G->>N: disconnectOutput(i)
Note over N,R: same cascade as above
end
G->>G: scan floatingLinks for node refs
G->>G: if SubgraphNode: check refs, maybe delete subgraph def
G->>N: node.onRemoved?.()
G->>N: node.graph = null
G->>G: _version++
loop each canvas
G->>C: deselect(node)
G->>C: delete selected_nodes[id]
end
G->>G: splice from _nodes[], delete from _nodes_by_id
G->>G: onNodeRemoved?.(node)
G->>C: setDirtyCanvas(true, true)
G->>G: afterChange() [undo checkpoint]
G->>G: updateExecutionOrder()
```
Problems: the graph method manually disconnects every slot, cleans up reroutes, scans floating links, checks subgraph references, notifies canvases, and recomputes execution order — all in one method that knows about every entity type.
### ECS Flow
```mermaid
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
Caller->>CS: removeNode(world, nodeId)
CS->>W: getComponent(nodeId, Connectivity)
W-->>CS: { inputSlotIds, outputSlotIds }
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)
end
CS->>W: getComponent(nodeId, WidgetContainer)
W-->>CS: { widgetIds }
loop each widgetId
CS->>W: deleteEntity(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()
```
### 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 |
## 2. Serialization
### Current Flow
`LGraph.serialize()``asSerialisable()` — walks every collection manually:
```mermaid
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant L as LLink
participant R as Reroute
participant Gr as LGraphGroup
participant SG as Subgraph
Caller->>G: serialize()
G->>G: asSerialisable()
loop each node
G->>N: node.serialize()
N->>N: snapshot inputs, outputs (with link IDs)
N->>N: snapshot properties
N->>N: collect widgets_values[]
N-->>G: ISerialisedNode
end
loop each link
G->>L: link.asSerialisable()
L-->>G: SerialisableLLink
end
loop each reroute
G->>R: reroute.asSerialisable()
R-->>G: SerialisableReroute
end
loop each group
G->>Gr: group.serialize()
Gr-->>G: ISerialisedGroup
end
G->>G: findUsedSubgraphIds()
loop each used subgraph
G->>SG: subgraph.asSerialisable()
Note over SG: recursively serializes internal nodes, links, etc.
SG-->>G: ExportedSubgraph
end
G-->>Caller: ISerialisedGraph
```
Problems: serialization logic lives in 6 different `serialize()` methods across 6 classes. Widget values are collected inline during node serialization. The graph walks its own collections — no separation of "what to serialize" from "how to serialize."
### ECS Flow
```mermaid
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
Caller->>SS: serialize(world)
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
W-->>SS: all node entities with their components
SS->>W: queryAll(LinkEndpoints)
W-->>SS: all link entities
SS->>W: queryAll(SlotIdentity, SlotConnection)
W-->>SS: all slot entities
SS->>W: queryAll(RerouteLinks, Position)
W-->>SS: all reroute entities
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-->>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 |
## 3. Deserialization
### Current Flow
`LGraph.configure(data)` — ~180 lines, two-phase node creation:
```mermaid
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant L as LLink
participant WVS as WidgetValueStore
Caller->>G: configure(data)
G->>G: clear() [destroy all existing entities]
G->>G: _configureBase(data) [set id, extra]
loop each serialized link
G->>L: LLink.create(linkData)
G->>G: _links.set(link.id, link)
end
loop each serialized reroute
G->>G: setReroute(rerouteData)
end
opt has subgraph definitions
G->>G: deduplicateSubgraphNodeIds()
loop each subgraph (topological order)
G->>G: createSubgraph(data)
end
end
rect rgb(60, 40, 40)
Note over G,N: Phase 1: Create nodes (unlinked)
loop each serialized node
G->>N: LiteGraph.createNode(type)
G->>G: graph.add(node) [assigns ID]
end
end
rect rgb(40, 60, 40)
Note over G,N: Phase 2: Configure nodes (links now resolvable)
loop each node
G->>N: node.configure(nodeData)
N->>N: create slots, restore properties
N->>N: resolve links from graph._links
N->>N: restore widget values
N->>WVS: widget.setNodeId() → register in store
N->>N: fire onConnectionsChange per linked slot
end
end
G->>G: add floating links
G->>G: validate reroutes
G->>G: _removeDuplicateLinks()
loop each serialized group
G->>G: create + configure group
end
G->>G: updateExecutionOrder()
```
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
### ECS Flow
```mermaid
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
participant LS as LayoutSystem
participant ES as ExecutionSystem
Caller->>SS: deserialize(world, data)
SS->>W: clear() [remove all entities]
Note over SS,W: All entities 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, {...})
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, {...})
end
Note over SS,W: Connectivity assembled from slot/link components
loop each widget in data
SS->>W: createEntity(WidgetEntityId)
SS->>W: setComponent(id, WidgetIdentity, {...})
SS->>W: setComponent(id, WidgetValue, {...})
end
SS->>SS: create reroutes, groups, subgraphs similarly
Note over SS,W: Systems react to populated World
SS->>LS: runLayout(world)
SS->>ES: computeExecutionOrder(world)
```
### 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 |
## 4. Pack Subgraph
### Current Flow
`LGraph.convertToSubgraph(items)` — clones nodes, computes boundaries, creates SubgraphNode:
```mermaid
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant SG as Subgraph
participant SGN as SubgraphNode
Caller->>G: convertToSubgraph(selectedItems)
G->>G: beforeChange()
G->>G: getBoundaryLinks(items)
Note over G: classify links as internal, boundary-in, boundary-out
G->>G: splitPositionables(items) → nodes, reroutes, groups
G->>N: multiClone(nodes) → cloned nodes (no links)
G->>G: serialize internal links, reroutes
G->>G: mapSubgraphInputsAndLinks(boundaryInputLinks)
G->>G: mapSubgraphOutputsAndLinks(boundaryOutputLinks)
G->>G: createSubgraph(exportedData)
G->>SG: subgraph.configure(data)
loop disconnect boundary links
G->>N: inputNode.disconnectInput()
G->>N: outputNode.disconnectOutput()
end
loop remove original items
G->>G: remove(node), remove(reroute), remove(group)
end
G->>SGN: LiteGraph.createNode(subgraph.id)
G->>G: graph.add(subgraphNode)
loop reconnect boundary inputs
G->>N: externalNode.connectSlots(output, subgraphNode, input)
end
loop reconnect boundary outputs
G->>SGN: subgraphNode.connectSlots(output, externalNode, input)
end
G->>G: afterChange()
```
Problems: 200+ lines in one method. Manual boundary link analysis. Clone-serialize-configure dance. Disconnect-remove-recreate-reconnect sequence with many intermediate states where the graph is inconsistent.
### ECS Flow
```mermaid
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
Caller->>CS: packSubgraph(world, selectedEntityIds)
CS->>W: query Connectivity + SlotConnection for selected nodes
CS->>CS: classify links as internal vs boundary
CS->>W: create new GraphId scope in scopes registry
Note over CS,W: Create SubgraphNode entity in parent scope
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' })
Note over CS,W: Re-parent selected entities into new graph scope
loop each selected entity
CS->>W: update graphScope to new graphId
end
Note over CS,W: Create boundary slots on SubgraphNode
loop each boundary input link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to target new slot
end
loop each boundary output link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to source from new slot
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 |
## 5. Unpack Subgraph
### Current Flow
`LGraph.unpackSubgraph(subgraphNode)` — clones internal nodes, remaps IDs, reconnects boundary:
```mermaid
sequenceDiagram
participant Caller
participant G as LGraph
participant SGN as SubgraphNode
participant SG as Subgraph
participant N as LGraphNode
Caller->>G: unpackSubgraph(subgraphNode)
G->>G: beforeChange()
G->>SG: get internal nodes
G->>N: multiClone(internalNodes)
loop each cloned node
G->>G: assign new ID (++lastNodeId)
G->>G: nodeIdMap[oldId] = newId
G->>G: graph.add(node)
G->>N: node.configure(info)
G->>N: node.setPos(pos + offset)
end
G->>G: clone and add groups
Note over G,SG: Remap internal links
loop each internal link
G->>G: remap origin_id/target_id via nodeIdMap
opt origin is SUBGRAPH_INPUT_ID
G->>G: resolve to external source via subgraphNode.inputs
end
opt target is SUBGRAPH_OUTPUT_ID
G->>G: resolve to external target via subgraphNode.outputs
end
end
G->>G: remove(subgraphNode)
G->>G: deduplicate links
G->>G: create new LLink objects in parent graph
G->>G: remap reroute parentIds
G->>G: afterChange()
```
Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20) require special-case handling. Boundary link resolution requires looking up the SubgraphNode's slots to find external connections.
### ECS Flow
```mermaid
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
W-->>CS: { graphId, interface }
CS->>W: query entities where graphScope = graphId
W-->>CS: all child entities (nodes, links, reroutes, etc.)
Note over CS,W: Re-parent entities to containing graph scope
loop each child entity
CS->>W: update graphScope to parent scope
end
Note over CS,W: Reconnect boundary links
loop each boundary slot in interface
CS->>W: getComponent(slotId, SlotConnection)
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
end
CS->>W: deleteEntity(subgraphNodeId)
CS->>W: remove graphId from scopes registry
Note over CS,W: Offset positions
loop each moved entity
CS->>W: update Position component
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 |
## 6. Connect Slots
### Current Flow
`LGraphNode.connectSlots()` — creates link, updates both endpoints, handles reroutes:
```mermaid
sequenceDiagram
participant Caller
participant N1 as SourceNode
participant N2 as TargetNode
participant G as LGraph
participant L as LLink
participant R as Reroute
participant LS as LayoutStore
Caller->>N1: connectSlots(output, targetNode, input)
N1->>N1: validate slot types
N1->>N2: onConnectInput?() → can reject
N1->>N1: onConnectOutput?() → can reject
opt input already connected
N1->>N2: disconnectInput(inputIndex)
end
N1->>L: new LLink(++lastLinkId, type, ...)
N1->>G: _links.set(link.id, link)
N1->>LS: layoutMutations.createLink()
N1->>N1: output.links.push(link.id)
N1->>N2: input.link = link.id
loop each reroute in path
N1->>R: reroute.linkIds.add(link.id)
end
N1->>G: _version++
N1->>N1: onConnectionsChange?(OUTPUT, ...)
N1->>N2: onConnectionsChange?(INPUT, ...)
N1->>G: setDirtyCanvas()
N1->>G: afterChange()
```
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
### ECS Flow
```mermaid
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
Caller->>CS: connect(world, outputSlotId, inputSlotId)
CS->>W: getComponent(inputSlotId, SlotConnection)
opt already connected
CS->>CS: removeLink(world, 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()
```
### 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, ...)` |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
## 7. Copy / Paste
### Current Flow
Copy: serialize selected items → clipboard. Paste: deserialize with new IDs.
```mermaid
sequenceDiagram
participant User
participant C as LGraphCanvas
participant G as LGraph
participant N as LGraphNode
participant CB as Clipboard
rect rgb(40, 40, 60)
Note over User,CB: Copy
User->>C: Ctrl+C
C->>C: _serializeItems(selectedItems)
loop each selected node
C->>N: node.clone().serialize()
C->>C: collect input links
end
C->>C: collect groups, reroutes
C->>C: recursively collect subgraph definitions
C->>CB: localStorage.setItem(JSON.stringify(data))
end
rect rgb(40, 60, 40)
Note over User,CB: Paste
User->>C: Ctrl+V
C->>CB: localStorage.getItem()
C->>C: _deserializeItems(parsed)
C->>C: remap subgraph IDs (new UUIDs)
C->>C: deduplicateSubgraphNodeIds()
C->>G: beforeChange()
loop each subgraph
C->>G: createSubgraph(data)
end
loop each node (id=-1 forces new ID)
C->>G: graph.add(node)
C->>N: node.configure(info)
end
loop each reroute
C->>G: setReroute(data)
C->>C: remap parentIds
end
loop each link
C->>N: outNode.connect(slot, inNode, slot)
end
C->>C: offset positions to cursor
C->>C: selectItems(created)
C->>G: afterChange()
end
```
Problems: clone-serialize-parse-remap-deserialize dance. Every entity type has
its own ID remapping logic. Subgraph IDs, node IDs, reroute IDs, and link
parent IDs all remapped independently. ~300 lines across multiple methods.
### ECS Flow
```mermaid
sequenceDiagram
participant User
participant CS as ClipboardSystem
participant W as World
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
end
rect rgb(40, 60, 40)
Note over User,CB: Paste
User->>CS: paste(world, position)
CS->>CB: retrieve snapshot
CS->>CS: generate ID remap table (old → new branded IDs)
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
end
CS->>CS: offset all Position components 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 |
## 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 |

View File

@@ -0,0 +1,722 @@
# ECS Migration Plan
A phased roadmap for migrating the litegraph entity system to the ECS
architecture described in [ADR 0008](../adr/0008-entity-component-system.md).
Each phase is independently shippable. Later phases depend on earlier ones
unless noted otherwise.
For the problem analysis, see [Entity Problems](entity-problems.md). For the
target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
For verified accuracy of these documents, see
[Appendix: Critical Analysis](appendix-critical-analysis.md).
## Planning assumptions
- The bridge period is expected to span 2-3 release cycles.
- Bridge work is treated as transitional debt with explicit owners and sunset
checkpoints, not as a permanent architecture layer.
- Phase 5 is entered only by explicit go/no-go review against the criteria in
this document.
## Phase 0: Foundation
Zero behavioral risk. Prepares the codebase for extraction without changing
runtime semantics. All items are independently shippable.
### 0a. Centralize version counter
`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.
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
increments.
```
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.
### 0b. Add missing ID type aliases
`NodeId`, `LinkId`, and `RerouteId` exist as type aliases. Two are missing:
| Type | Definition | Location |
| ----------- | ---------- | ---------------------------------------------------------------- |
| `GroupId` | `number` | `LGraphGroup.ts` (currently implicit on `id: number` at line 39) |
| `SlotIndex` | `number` | `interfaces.ts` (slot positions are untyped `number` everywhere) |
**Change:** Add the type aliases, update property declarations, re-export from
barrel (`litegraph.ts`).
**Why:** Foundation for branded IDs. Type aliases are erased at compile time —
zero runtime impact.
**Risk:** None. Type-only change.
### 0c. Fix architecture doc errors
Five factual errors verified during code review (see
[Appendix](appendix-critical-analysis.md#vii-summary-of-findings)):
- `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
Introduces the ECS type vocabulary and an empty World. No migration of existing
code — new types coexist with old ones.
### 1a. Branded entity ID types
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:
```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
}
```
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.
World 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`.
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
persistence. The World exists but nothing populates it yet.
**Risk:** Low. New code, no integration points.
---
## Phase 2: Bridge Layer
Connects the legacy class instances to the World. Both old and new code can
read entity state; writes still go through legacy classes.
### 2a. Read-only bridge for Position
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.
**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.
**Open question:** Should the World wrap the Y.js maps or maintain its own
plain-data copy? Options:
| 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 |
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
copy is cheap (position is small data). Revisit if sync overhead becomes
measurable.
**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).
### 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.
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
reading from `LGraphNode` instances. These are simple property copies.
**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.)
**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.
### Bridge sunset criteria (applies to every Phase 2 bridge)
A bridge 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.
These criteria prevent the bridge from becoming permanent by default.
### Bridge 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
review and revised removal plan.
---
## Phase 3: Systems
Introduce system functions that operate on World 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.
**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.
**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.
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
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.
### 3c. ConnectivitySystem (queries only)
A system that can answer connectivity queries by reading `Connectivity`,
`SlotConnection`, and `LinkEndpoints` components from the World:
- "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.
**Risk:** Low. Read-only system with equivalence tests.
---
## Phase 4: Write Path Migration
Systems begin owning mutations. Legacy class methods delegate to systems.
This is the highest-risk phase.
### 4a. Position writes through World
New code writes position via `world.setComponent(nodeId, Position, ...)`.
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
**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.
**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).
### 4b. ConnectivitySystem mutations
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
system.
**Extension API concern:** The current system fires callbacks at each step:
- `onConnectInput()` / `onConnectOutput()` — can reject connections
- `onConnectionsChange()` — notifies after connection change
- `onRemoved()` — notifies after node removal
These callbacks are the **extension API contract**. The ConnectivitySystem
must fire them at the same points in the operation, or extensions break.
**Recommended approach:** The system emits lifecycle events that the bridge
layer translates into legacy callbacks. This preserves the contract without
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,
and no lifecycle events.
- `onConnectionsChange()` fires synchronously after commit, preserving current
source-then-target ordering.
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
compatibility API during Phase 4.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
### 4c. Widget write path
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.
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
change is routing writes through the World instead of the store.
### 4d. Layout write path and render decoupling
Remove layout side effects from render incrementally by node family.
**Approach:**
1. Inventory `drawNode()` call paths that still trigger `arrange()`.
2. For one node family at a time, run `LayoutSystem` in update phase and mark
entities as layout-clean before render.
3. Keep a temporary compatibility fallback that runs legacy layout only for
non-migrated families.
4. Delete fallback once parity tests and frame-time budgets are met.
**Risk:** High. Mixed-mode operation must avoid stale layout reads. Requires
family-level rollout and targeted regression tests.
### Render hot-path performance gate
Before enabling ECS render reads as default for any migrated family:
- Benchmark representative workflows (200-node and 500-node minimum).
- 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.
### 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.
- Undo batching parity is proven: one logical user action yields one undo
checkpoint in both legacy and ECS 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
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
reads.
---
## Phase 5: Legacy Removal
Remove bridge layers and deprecated class properties. This phase happens
per-component, not all at once.
### 5a. Remove Position bridge
Once all position reads and writes go through the World, remove the bridge
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
### 5b. Remove widget class hierarchy
Once all widget behavior is in systems, the 23+ widget subclasses can be
replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
`ComboWidget`, etc. become configuration data rather than class instances.
### 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.
**Risk:** Very High. This is the irreversible step. Must be done only after
thorough validation that all consumers (including extensions) work with the
ECS path.
### Phase 4 -> 5 exit criteria (required)
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.
- Serialization equivalence tests pass continuously for one release cycle.
- A representative extension compatibility matrix is green, including
`rgthree-comfy`.
- Bridge instrumentation shows zero fallback-path usage in integration and e2e
suites.
- A rollback plan exists for each removal PR until the release is cut.
- ECS write path has run as default behind a kill switch for at least one full
release cycle.
- No unresolved P0/P1 extension regressions are attributed to ECS migration in
that cycle.
### Phase 5 trigger packet (required before first legacy-removal PR)
The team prepares a single go/no-go packet containing:
- Phase 4 -> 5 criteria checklist with links to evidence.
- Extension compatibility matrix results.
- Bridge fallback usage report (must be zero for the target concern).
- Performance gate report for ECS render/read paths.
- Rollback owner, rollback steps, and release coordination sign-off.
---
## Open Questions
### 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.
**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.
**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?
### Extension API preservation
The current system exposes lifecycle callbacks on entity classes:
| Callback | Class | Purpose |
| --------------------- | ------------ | ----------------------------------- |
| `onConnectInput` | `LGraphNode` | Validate/reject incoming connection |
| `onConnectOutput` | `LGraphNode` | Validate/reject outgoing connection |
| `onConnectionsChange` | `LGraphNode` | React to topology change |
| `onRemoved` | `LGraphNode` | Cleanup on deletion |
| `onAdded` | `LGraphNode` | Setup on graph insertion |
| `onConfigure` | `LGraphNode` | Post-deserialization hook |
| `onWidgetChanged` | `LGraphNode` | React to widget value change |
Extensions register these callbacks to customize node behavior. The ECS
migration must preserve this contract or provide a documented migration path
for extension authors.
**Recommended approach:** Define an `EntityLifecycleEvent` system that emits
typed events at the same points where callbacks currently fire. The bridge
layer translates events into legacy callbacks. Extensions can gradually adopt
event listeners instead of callbacks.
**Phase 4 decisions:**
- Rejection callbacks act as pre-commit guards (reject before World mutation).
- Callback dispatch remains synchronous during the bridge period.
- Callback order remains: output validation -> input validation -> commit ->
output change notification -> input change notification.
### Extension Migration Examples (old -> new)
The bridge keeps legacy callbacks working, but extension authors can migrate
incrementally to ECS-native patterns.
#### 1) Widget lookup by name
```ts
// Legacy pattern
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')
if (seedWidgetId) {
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
if (widgetValue) {
world.setComponent(seedWidgetId, WidgetValue, {
...widgetValue,
value: 42
})
}
}
```
#### 2) `onConnectionsChange` callback
```ts
// Legacy pattern
nodeType.prototype.onConnectionsChange = function (
side,
slot,
connected,
linkInfo
) {
updateExtensionState(this.id, side, slot, connected, linkInfo)
}
// ECS pattern
lifecycleEvents.on('connection.changed', (event) => {
if (event.nodeId !== nodeId) return
updateExtensionState(
event.nodeId,
event.side,
event.slotIndex,
event.connected,
event.linkInfo
)
})
```
#### 3) `onRemoved` callback
```ts
// Legacy pattern
nodeType.prototype.onRemoved = function () {
cleanupExtensionResources(this.id)
}
// ECS pattern
lifecycleEvents.on('entity.removed', (event) => {
if (event.kind !== 'node' || event.entityId !== nodeId) return
cleanupExtensionResources(event.entityId)
})
```
#### 4) `graph._version++`
```ts
// Legacy pattern (do not add new usages)
graph._version++
// Bridge-safe transitional pattern (Phase 0a)
graph.incrementVersion()
// ECS-native pattern: mutate through 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 })
}
})
```
**Question to resolve after compatibility parity:**
- Should ECS-native lifecycle events stay synchronous after bridge removal, or
can they become asynchronous once legacy callback compatibility is dropped?
### 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.
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
checkpoints but not true transactions. The graph can be in an inconsistent
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()` /
`afterChange()` bracket.
- Operations with multiple component 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
version increments.
**Questions to resolve:**
- How should `world.transaction()` interact with Y.js transactions when a
component is CRDT-backed?
- 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:
| Store | Key Format |
| ----------------------- | --------------------------------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
| DomWidgetStore | Widget UUID |
| LayoutStore | Raw nodeId/linkId/rerouteId |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
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.
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
for the transition period.
---
## Dependency Graph
```
Phase 0a (incrementVersion) ──┐
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 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
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 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
Phase 4->5 exit criteria ──────── depends on all of Phase 4
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
```
## 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 |
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,
and Phase 5 is the point of no return.

View File

@@ -0,0 +1,568 @@
# ECS Target Architecture
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
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.
```mermaid
graph TD
subgraph World["World (Central Registry)"]
direction TB
NodeStore["Nodes
Map&lt;NodeEntityId, NodeComponents&gt;"]
LinkStore["Links
Map&lt;LinkEntityId, LinkComponents&gt;"]
ScopeRegistry["Graph Scopes
Map&lt;GraphId, ParentGraphId | null&gt;"]
WidgetStore["Widgets
Map&lt;WidgetEntityId, WidgetComponents&gt;"]
SlotStore["Slots
Map&lt;SlotEntityId, SlotComponents&gt;"]
RerouteStore["Reroutes
Map&lt;RerouteEntityId, RerouteComponents&gt;"]
GroupStore["Groups
Map&lt;GroupEntityId, GroupComponents&gt;"]
end
subgraph Systems["Systems (Behavior)"]
direction TB
RS["RenderSystem"]
SS["SerializationSystem"]
CS["ConnectivitySystem"]
LS["LayoutSystem"]
ES["ExecutionSystem"]
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
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
```
### Entity IDs
```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' }"]
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
```
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
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).
### Linked subgraphs and instance-varying state
Linked subgraph definitions can be shared structurally, but mutable values are
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.
### Recursive subgraphs without inheritance
Recursive containment is represented through graph scopes rather than
`Subgraph extends LGraph` inheritance.
- A subgraph node points to a child graph via
`SubgraphStructure.childGraphId`.
- The scope registry stores `childGraphId -> parentGraphId` links.
- Depth queries traverse this scope DAG, then filter entities by `graphScope`.
## 2. Component Composition
### Node: Before vs After
```mermaid
graph LR
subgraph Before["LGraphNode (monolith)"]
direction TB
B1["pos, size, bounding"]
B2["color, bgcolor, title"]
B3["type, category, nodeData"]
B4["inputs[], outputs[]"]
B5["order, mode, flags"]
B6["properties, properties_info"]
B7["widgets[]"]
B8["serialize(), configure()"]
B9["drawSlots(), drawWidgets()"]
B10["execute(), triggerSlot()"]
B11["graph._version++"]
B12["connect(), disconnect()"]
end
subgraph After["NodeEntityId + Components"]
direction TB
A1["Position
{ pos, size, bounding }"]
A2["NodeVisual
{ color, bgcolor, boxcolor, title }"]
A3["NodeType
{ type, category, nodeData }"]
A4["Connectivity
{ inputSlotIds[], outputSlotIds[] }"]
A5["Execution
{ order, mode, flags }"]
A6["Properties
{ properties, propertiesInfo }"]
A7["WidgetContainer
{ widgetIds[] }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.-> A4
B5 -.-> A5
B6 -.-> A6
B7 -.-> A7
B8 -.->|"moves to"| SYS1["SerializationSystem"]
B9 -.->|"moves to"| SYS2["RenderSystem"]
B10 -.->|"moves to"| SYS3["ExecutionSystem"]
B11 -.->|"moves to"| SYS4["VersionSystem"]
B12 -.->|"moves to"| SYS5["ConnectivitySystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
### Link: Before vs After
```mermaid
graph LR
subgraph Before["LLink (class)"]
direction TB
B1["origin_id, origin_slot
target_id, target_slot, type"]
B2["color, path, _pos"]
B3["_dragging, data"]
B4["disconnect()"]
B5["resolve()"]
end
subgraph After["LinkEntityId + Components"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
A2["LinkVisual
{ color, path, centerPos }"]
A3["LinkState
{ dragging, data }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["ConnectivitySystem"]
B5 -.->|"moves to"| SYS2["ConnectivitySystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
### Widget: Before vs After
```mermaid
graph LR
subgraph Before["BaseWidget (class)"]
direction TB
B1["name, type, _node"]
B2["value, options, serialize"]
B3["computedHeight, margin"]
B4["drawWidget(), onClick()"]
B5["useWidgetValueStore()"]
B6["usePromotionStore()"]
end
subgraph After["WidgetEntityId + Components"]
direction TB
A1["WidgetIdentity
{ name, widgetType, parentNodeId }"]
A2["WidgetValue
{ value, options, serialize }"]
A3["WidgetLayout
{ computedHeight, constraints }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["RenderSystem"]
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
B6 -.->|"moves to"| SYS3["PromotionSystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
## 3. System Architecture
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
```mermaid
graph TD
subgraph InputPhase["Input Phase"]
UserInput["User Input
(pointer, keyboard)"]
APIInput["API Input
(backend execution results)"]
end
subgraph UpdatePhase["Update Phase (ordered)"]
direction TB
CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
Writes: version counter"]
LS["LayoutSystem
Computes positions and sizes.
Runs BEFORE render, not during.
Reads: Connectivity, WidgetContainer
Writes: Position, SlotVisual, WidgetLayout"]
ES["ExecutionSystem
Determines run order.
Reads: Connectivity, Execution
Writes: Execution.order"]
end
subgraph RenderPhase["Render Phase (read-only)"]
RS["RenderSystem
Pure read of components.
No state mutation.
Reads: Position, *Visual, *Layout"]
end
subgraph PersistPhase["Persist Phase"]
SS["SerializationSystem
Reads/writes all components.
Handles workflow JSON."]
end
UserInput --> CS
APIInput --> ES
CS --> VS
VS --> LS
LS --> RS
CS --> SS
style InputPhase fill:#2a2a4a,stroke:#3a3a5a,color:#e0e0e0
style UpdatePhase fill:#1a3a2a,stroke:#2a4a3a,color:#e0e0e0
style RenderPhase fill:#3a2a1a,stroke:#4a3a2a,color:#e0e0e0
style PersistPhase fill:#2a2a3a,stroke:#3a3a4a,color:#e0e0e0
```
### System-Component Access Matrix
```mermaid
graph LR
subgraph Systems
RS["Render"]
SS["Serialization"]
CS["Connectivity"]
LS["Layout"]
ES["Execution"]
VS["Version"]
end
subgraph Components
Pos["Position"]
NV["NodeVisual"]
NT["NodeType"]
Con["Connectivity"]
Exe["Execution"]
Props["Properties"]
WC["WidgetContainer"]
LE["LinkEndpoints"]
LV["LinkVisual"]
SC["SlotConnection"]
SV["SlotVisual"]
WVal["WidgetValue"]
WL["WidgetLayout"]
end
RS -.->|read| Pos
RS -.->|read| NV
RS -.->|read| LV
RS -.->|read| SV
RS -.->|read| WL
LS -->|write| Pos
LS -->|write| SV
LS -->|write| WL
LS -.->|read| Con
LS -.->|read| WC
CS -->|write| LE
CS -->|write| SC
CS -->|write| Con
ES -.->|read| Con
ES -->|write| Exe
SS -.->|read/write| Pos
SS -.->|read/write| NT
SS -.->|read/write| Props
SS -.->|read/write| WVal
SS -.->|read/write| LE
VS -.->|read| Pos
VS -.->|read| Con
```
## 4. Dependency Flow
### Before: Tangled References
```mermaid
graph TD
Node["LGraphNode"] <-->|"circular"| Graph["LGraph"]
Graph <-->|"circular"| Subgraph["Subgraph"]
Node -->|"this.graph._links"| Links["LLink Map"]
Node -->|"this.graph.getNodeById"| Node
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
Canvas -->|"node.graph.remove(node)"| Graph
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
Widget -->|"usePromotionStore()"| Store2["Pinia Store"]
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
Graph -->|"useLayoutMutations()"| Store3
LLink["LLink"] -->|"useLayoutMutations()"| Store3
style Node fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Graph fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Canvas fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Widget fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
```
### After: Unidirectional Data Flow
```mermaid
graph TD
subgraph Systems["Systems"]
RS["RenderSystem"]
CS["ConnectivitySystem"]
LS["LayoutSystem"]
ES["ExecutionSystem"]
SS["SerializationSystem"]
VS["VersionSystem"]
end
World["World
(instance-scoped source of truth)"]
subgraph Components["Component Stores"]
Pos["Position"]
Vis["*Visual"]
Con["Connectivity"]
Val["*Value"]
end
Systems -->|"query/mutate"| World
World -->|"contains"| Components
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
style Components 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)
- **Instance safety**: linked definitions can be reused without forcing shared
mutable widget/execution state across instances
## 5. Problem Resolution Map
How each problem from [entity-problems.md](entity-problems.md) is resolved:
```mermaid
graph LR
subgraph Problems["Current Problems"]
P1["God Objects
(9k+ line classes)"]
P2["Circular Deps
(LGraph ↔ Subgraph)"]
P3["Mixed Concerns
(render + domain + state)"]
P4["Inconsistent IDs
(number|string, no safety)"]
P5["Demeter Violations
(graph._links, graph._version++)"]
P6["Scattered Side Effects
(15+ _version++ sites)"]
P7["Render-Time Mutations
(arrange() during draw)"]
end
subgraph Solutions["ECS Solutions"]
S1["Components: small, focused
data objects (5-10 fields each)"]
S2["Entities are just IDs.
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.
No entity→entity refs."]
S6["VersionSystem owns
all change tracking."]
S7["LayoutSystem runs in
update phase, before render.
RenderSystem is read-only."]
end
P1 --> S1
P2 --> S2
P3 --> S3
P4 --> S4
P5 --> S5
P6 --> S6
P7 --> S7
style Problems fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Solutions fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
## 6. Migration Bridge
The migration is incremental. During the transition, a bridge layer keeps legacy class properties and ECS components in sync.
```mermaid
sequenceDiagram
participant Legacy as Legacy Code
participant Class as LGraphNode (class)
participant Bridge as Bridge Adapter
participant World as World (ECS)
participant New as New Code / Systems
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
Legacy->>Class: node.pos = [100, 200]
Class->>Bridge: pos setter intercepted
Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
New->>World: world.getComponent(nodeId, Position)
World-->>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
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] }
```
### Incremental layout/render separation
Layout extraction is staged by node family, not all-at-once:
1. Mark `arrange()` as deprecated in render paths and collect call-site
telemetry.
2. Run `LayoutSystem` during update for a selected node family behind a feature
gate.
3. Keep a temporary compatibility fallback for un-migrated node families only.
4. Remove the fallback once parity tests and frame-time budgets pass.
This keeps `RenderSystem` read-only for migrated families while preserving
incremental rollout safety.
### Migration Phases
```mermaid
graph LR
subgraph Phase1["Phase 1: Types Only"]
T1["Define branded IDs"]
T2["Define component interfaces"]
T3["Define World type"]
end
subgraph Phase2["Phase 2: Bridge"]
B1["Bridge adapters
class ↔ World sync"]
B2["New features use
World as source"]
B3["Old code unchanged"]
end
subgraph Phase3["Phase 3: Extract"]
E1["Migrate one component
at a time"]
E2["Deprecate class
properties"]
E3["Systems replace
methods"]
end
subgraph Phase4["Phase 4: Clean"]
C1["Remove bridge"]
C2["Remove legacy classes"]
C3["Systems are sole
behavior layer"]
end
Phase1 --> Phase2 --> Phase3 --> Phase4
style Phase1 fill:#1a2a4a,stroke:#2a3a5a,color:#e0e0e0
style Phase2 fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
style Phase3 fill:#2a3a1a,stroke:#3a4a2a,color:#e0e0e0
style Phase4 fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
This diagram is intentionally high level. The operational Phase 4 -> 5 entry
criteria (compatibility matrix, bridge fallback usage, rollback requirements)
are defined in [ecs-migration-plan.md](ecs-migration-plan.md).

View File

@@ -0,0 +1,349 @@
# 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 (03).
During Phases 03, 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.

View File

@@ -0,0 +1,441 @@
# 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).
## Entities
| Entity | Class | ID Type | Primary Location |
| -------- | ------------- | --------------- | ---------------------------------------------------------------------------- |
| Graph | `LGraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` |
| Node | `LGraphNode` | `NodeId` | `src/lib/litegraph/src/LGraphNode.ts` |
| Link | `LLink` | `LinkId` | `src/lib/litegraph/src/LLink.ts` |
| Subgraph | `Subgraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` (ECS: node component, not separate entity) |
| Widget | `BaseWidget` | name + nodeId | `src/lib/litegraph/src/widgets/BaseWidget.ts` |
| Slot | `SlotBase` | index on parent | `src/lib/litegraph/src/node/SlotBase.ts` |
| Reroute | `Reroute` | `RerouteId` | `src/lib/litegraph/src/Reroute.ts` |
| Group | `LGraphGroup` | `number` | `src/lib/litegraph/src/LGraphGroup.ts` |
Under the ECS model, subgraphs are not a separate entity kind — they are nodes with `SubgraphStructure` and `SubgraphMeta` components. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
## 1. Overview
High-level ownership and reference relationships between all entities.
```mermaid
graph TD
subgraph Legend
direction LR
L1[A] -->|owns| L2[B]
L3[C] -.->|references| L4[D]
L5[E] ==>|extends| L6[F]
end
Graph["LGraph
(UUID)"]
Node["LGraphNode
(NodeId)"]
SubgraphEntity["Subgraph
(UUID)"]
SubgraphNode["SubgraphNode"]
Link["LLink
(LinkId)"]
Widget["BaseWidget
(name)"]
Slot["SlotBase
(index)"]
Reroute["Reroute
(RerouteId)"]
Group["LGraphGroup
(number)"]
Canvas["LGraphCanvas"]
%% Ownership (solid)
Graph -->|"_nodes[]"| Node
Graph -->|"_links Map"| Link
Graph -->|"reroutes Map"| Reroute
Graph -->|"_groups[]"| Group
Graph -->|"_subgraphs Map"| SubgraphEntity
Node -->|"inputs[], outputs[]"| Slot
Node -->|"widgets[]"| Widget
%% Extends (thick)
SubgraphEntity ==>|extends| Graph
SubgraphNode ==>|extends| Node
%% References (dashed)
Link -.->|"origin_id, target_id"| Node
Link -.->|"parentId"| Reroute
Slot -.->|"link / links[]"| Link
Reroute -.->|"linkIds"| Link
Reroute -.->|"parentId"| Reroute
Group -.->|"_children Set"| Node
Group -.->|"_children Set"| Reroute
SubgraphNode -.->|"subgraph"| SubgraphEntity
Node -.->|"graph"| Graph
Canvas -.->|"graph"| Graph
Canvas -.->|"selectedItems"| Node
Canvas -.->|"selectedItems"| Group
Canvas -.->|"selectedItems"| Reroute
```
## 2. Connectivity
How Nodes, Slots, Links, and Reroutes form the graph topology.
```mermaid
graph LR
subgraph OutputNode["Origin Node"]
OSlot["Output Slot
links: LinkId[]"]
end
subgraph InputNode["Target Node"]
ISlot["Input Slot
link: LinkId | null"]
end
OSlot -->|"LinkId ref"| Link["LLink
origin_id + origin_slot
target_id + target_slot
type: ISlotType"]
Link -->|"LinkId ref"| ISlot
Link -.->|"parentId"| R1["Reroute A"]
R1 -.->|"parentId"| R2["Reroute B"]
R1 -.-|"linkIds Set"| Link
R2 -.-|"linkIds Set"| Link
```
### Subgraph Boundary Connections
```mermaid
graph TD
subgraph ParentGraph["Parent Graph"]
ExtNode["External Node"]
SGNode["SubgraphNode
(in parent graph)"]
end
subgraph SubgraphDef["Subgraph"]
SInput["SubgraphInput"]
SInputNode["SubgraphInputNode
(virtual)"]
InternalNode["Internal Node"]
SOutputNode["SubgraphOutputNode
(virtual)"]
SOutput["SubgraphOutput"]
end
ExtNode -->|"Link (parent graph)"| SGNode
SGNode -.->|"maps to"| SInput
SInput -->|"owns"| SInputNode
SInputNode -->|"Link (subgraph)"| InternalNode
InternalNode -->|"Link (subgraph)"| SOutputNode
SOutputNode -->|"owned by"| SOutput
SOutput -.->|"maps to"| SGNode
SGNode -->|"Link (parent graph)"| ExtNode
```
### Floating Links (In-Progress Connections)
```mermaid
graph LR
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
origin_id=-1 or target_id=-1"]
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
FL -.->|"may pass through"| Reroute
Reroute -.-|"floatingLinkIds Set"| FL
FL -->|"on drop"| Permanent["Permanent LLink
(registered in graph._links)"]
```
## 3. Rendering
How LGraphCanvas draws each entity type.
```mermaid
graph TD
Canvas["LGraphCanvas
render loop"]
Canvas -->|"1. background"| DrawGroups["drawGroups()"]
Canvas -->|"2. connections"| DrawConns["drawConnections()"]
Canvas -->|"3. foreground"| DrawNodes["drawNode() per node"]
Canvas -->|"4. in-progress"| DrawLC["LinkConnector.renderLinks"]
DrawGroups --> Group["group.draw(canvas, ctx)"]
DrawConns --> LinkSeg["LinkSegment interface"]
LinkSeg --> Link["LLink path rendering"]
LinkSeg --> RerouteRender["Reroute inline rendering
(draw, drawSlots)"]
DrawNodes --> NodeDraw["node drawing pipeline"]
NodeDraw -->|"drawSlots()"| SlotDraw["slot.draw() per slot"]
NodeDraw -->|"drawWidgets()"| WidgetDraw["widget.drawWidget() per widget"]
NodeDraw -->|"title, badges"| NodeChrome["title bar, buttons, badges"]
DrawLC --> FloatingViz["Floating link visualization"]
```
### Rendering Order Detail
```mermaid
sequenceDiagram
participant C as Canvas
participant Gr as Groups
participant L as Links/Reroutes
participant N as Nodes
participant S as Slots
participant W as Widgets
C->>Gr: drawGroups() — background layer
Gr-->>C: group shapes + titles
C->>L: drawConnections() — middle layer
L-->>C: bezier paths + reroute dots
loop each node (back to front)
C->>N: drawNode()
N->>N: drawNodeShape() (background, title)
N->>S: drawSlots() (input/output circles)
S-->>N: slot shapes + labels
N->>W: drawWidgets() (if not collapsed)
W-->>N: widget UI elements
N-->>C: complete node
end
C->>C: overlay (tooltips, debug)
```
## 4. Lifecycle
Creation and destruction flows for each entity.
### Node Lifecycle
```mermaid
stateDiagram-v2
[*] --> Created: new LGraphNode(title)
Created --> Configured: node.configure(data)
Configured --> InGraph: graph.add(node)
state InGraph {
[*] --> Active
Active --> Active: connect/disconnect slots
Active --> Active: add/remove widgets
Active --> Active: move, resize, collapse
}
InGraph --> Removed: graph.remove(node)
Removed --> [*]
note right of Created
Constructor sets defaults.
No graph reference yet.
end note
note right of InGraph
node.onAdded(graph) called.
ID assigned from graph.state.
Slots may trigger onConnectionsChange.
end note
note right of Removed
All links disconnected.
node.onRemoved() called.
Removed from graph._nodes.
end note
```
### Link Lifecycle
```mermaid
stateDiagram-v2
[*] --> Created: node.connect() or connectSlots()
Created --> Registered: graph._links.set(id, link)
state Registered {
[*] --> Active
Active --> Active: data flows through
Active --> Active: reroutes added/removed
}
Registered --> Disconnected: node.disconnectInput/Output()
Disconnected --> Removed: link.disconnect(network)
Removed --> [*]
note right of Created
new LLink(id, type, origin, slot, target, slot)
Output slot.links[] updated.
Input slot.link set.
end note
note right of Removed
Removed from graph._links.
Orphaned reroutes cleaned up.
graph._version incremented.
end note
```
### Widget Lifecycle
```mermaid
stateDiagram-v2
[*] --> Created: node.addWidget(type, name, value, options)
Created --> Concrete: toConcreteWidget()
Concrete --> Bound: widget.setNodeId(nodeId)
state Bound {
[*] --> Active
Active --> Active: setValue() → store + node callback
Active --> Active: draw(), onClick(), onDrag()
}
Bound --> Removed: node.removeWidget(widget)
Removed --> [*]
note right of Bound
Registered in WidgetValueStore.
State keyed by graphId:nodeId:name.
Value reads/writes via store.
end note
```
### Subgraph Lifecycle
```mermaid
stateDiagram-v2
[*] --> Created: graph.createSubgraph(data)
state Created {
[*] --> Defined
Defined: registered in rootGraph._subgraphs
}
Created --> Instantiated: new SubgraphNode(subgraph)
Instantiated --> InGraph: graph.add(subgraphNode)
state InGraph {
[*] --> Active
Active --> Active: add/remove inputs/outputs
Active --> Active: promote/demote widgets
Active --> Active: edit internal nodes
}
InGraph --> Unpacked: graph.unpackSubgraph(node)
Unpacked --> [*]
InGraph --> NodeRemoved: graph.remove(subgraphNode)
NodeRemoved --> MaybePurged: no other SubgraphNodes reference it?
MaybePurged --> [*]
note right of Instantiated
SubgraphNode.subgraph = subgraph.
Inputs/outputs synced from subgraph.
end note
note right of Unpacked
Internal nodes cloned to parent.
Links remapped. SubgraphNode removed.
Subgraph def removed if unreferenced.
end note
```
## 5. State Management
External stores and their relationships to entities.
```mermaid
graph TD
subgraph Entities
Node["LGraphNode"]
Widget["BaseWidget"]
Reroute["Reroute"]
Link["LLink"]
Graph["LGraph"]
SGNode["SubgraphNode"]
end
subgraph Stores
WVS["WidgetValueStore
(Pinia)"]
PS["PromotionStore
(Pinia)"]
LM["LayoutMutations
(composable)"]
end
subgraph GraphState["Graph Internal State"]
Version["graph._version"]
LGState["graph.state
(lastNodeId, lastLinkId,
lastRerouteId, lastGroupId)"]
end
%% WidgetValueStore
Widget -->|"setNodeId() registers"| WVS
Widget <-->|"value, label, disabled"| WVS
WVS -.->|"keyed by graphId:nodeId:name"| Widget
%% PromotionStore
SGNode -->|"tracks promoted widgets"| PS
Widget -.->|"isPromotedByAny() query"| PS
%% LayoutMutations
Node -->|"pos/size setter"| LM
Reroute -->|"move()"| LM
Link -->|"connectSlots()/disconnect()"| LM
Graph -->|"add()/remove()"| LM
%% Graph state
Node -->|"connect/disconnect"| Version
Widget -->|"setValue()"| Version
Node -->|"collapse/toggleAdvanced"| Version
Graph -->|"add/remove entities"| LGState
```
### Change Notification Flow
```mermaid
sequenceDiagram
participant E as Entity (Node/Widget/Link)
participant G as LGraph
participant C as LGraphCanvas
participant R as Render Loop
E->>G: graph._version++
E->>G: graph.beforeChange() (undo checkpoint)
Note over E,G: ... mutation happens ...
E->>G: graph.afterChange() (undo checkpoint)
E->>G: graph.change()
G->>C: canvasAction → canvas.setDirty(true, true)
C->>R: dirty flags checked on next frame
R->>C: full redraw
```
### Widget State Delegation
```mermaid
sequenceDiagram
participant N as Node
participant W as Widget
participant S as WidgetValueStore
participant G as Graph
N->>W: addWidget(type, name, value)
W->>W: toConcreteWidget()
N->>W: setNodeId(nodeId)
W->>S: registerWidget(graphId, state)
S-->>W: state reference stored in widget._state
Note over W,S: All value access now goes through store
W->>S: widget.value = newVal (setter)
S-->>S: store.state.value = newVal
W->>N: node.onWidgetChanged?.(name, val)
W->>G: graph._version++
```

View File

@@ -0,0 +1,214 @@
# Entity System Structural Problems
This document catalogs the structural problems in the current litegraph entity system. It provides the concrete "why" behind the ECS migration proposed in [ADR 0008](../adr/0008-entity-component-system.md). For the as-is relationship map, see [Entity Interactions](entity-interactions.md).
All file references are relative to `src/lib/litegraph/src/`.
## 1. God Objects
The three largest classes carry far too many responsibilities:
| Class | Lines | Responsibilities |
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `LGraphCanvas` | ~9,100 | Rendering, input handling, selection, link dragging, context menus, clipboard, undo/redo hooks, node layout triggers |
| `LGraphNode` | ~4,300 | Domain model, connectivity, serialization, rendering (slots, widgets, badges, title), layout, execution, property management |
| `LGraph` | ~3,100 | Container management, serialization, canvas notification, subgraph lifecycle, execution ordering, link deduplication |
`LGraphNode` alone has ~539 method/property definitions. A sampling of the concerns it mixes:
| Concern | Examples |
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Rendering | `renderingColor` (line 328), `renderingBgColor` (line 335), `drawSlots()`, `drawWidgets()`, `measure(ctx)` (line 2074) |
| Serialization | `serialize()` (line 943), `configure()` (line 831), `toJSON()` (line 1033) |
| Connectivity | `connect()`, `connectSlots()`, `disconnectInput()`, `disconnectOutput()` |
| Execution | `execute()` (line 1418), `triggerSlot()` |
| Layout | `arrange()`, `_arrangeWidgets()`, `computeSize()` |
| State mgmt | `setProperty()`, `onWidgetChanged()`, direct `graph._version++` |
## 2. Circular Dependencies
**LGraph ↔ Subgraph**: `Subgraph` extends `LGraph`, but `LGraph` creates and manages `Subgraph` instances. This forces:
- A barrel export in `litegraph.ts` that re-exports 40+ modules with **order-dependent imports**
- An explicit comment at `litegraph.ts:15`: _"Must remain above LiteGraphGlobal (circular dependency due to abstract factory behaviour in 'configure')"_
- Test files must use the barrel import (`import { LGraph, Subgraph } from '.../litegraph'`) rather than direct imports, or they break
The `Subgraph` class is defined inside `LGraph.ts` (line 2761) rather than in its own file — a symptom of the circular dependency being unresolvable with the current class hierarchy.
## 3. Mixed Concerns
### Rendering in Domain Objects
`LGraphNode.measure()` (line 2074) accepts a `CanvasRenderingContext2D` parameter and sets `ctx.font` — a rendering operation embedded in what should be a domain model:
```
measure(ctx?: CanvasRenderingContext2D, options?: MeasureOptions): void {
...
if (ctx) ctx.font = this.innerFontStyle
```
### State Mutation During Render
`LGraphCanvas.drawNode()` (line 5554) mutates node state as a side effect of rendering:
- Line 5562: `node._setConcreteSlots()` — rebuilds slot arrays
- Line 5564: `node.arrange()` — recalculates widget layout
- Lines 5653-5655: same mutations repeated for a second code path
This means the render pass is not idempotent — drawing a node changes its state.
### Store Dependencies in Domain Objects
`BaseWidget` (line 20-22) imports two Pinia stores at the module level:
- `usePromotionStore` — queried on every `getOutlineColor()` call
- `useWidgetValueStore` — widget state delegation via `setNodeId()`
Similarly, `LGraph` (lines 10-13) imports `useLayoutMutations`, `usePromotionStore`, and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
### Serialization Interleaved with Container Logic
`LGraph.configure()` (line 2400) mixes deserialization, event dispatch, store clearing, and container state setup in a single 180-line method. A change to serialization format risks breaking container lifecycle, and vice versa.
## 4. Inconsistent ID Systems
### Ambiguous NodeId
```ts
export type NodeId = number | string // LGraphNode.ts:100
```
Most nodes use numeric IDs, but subgraph-related nodes use strings. Code must use runtime type guards (`typeof node.id === 'number'` at LGraph.ts:978, LGraphCanvas.ts:9045). This is a source of subtle bugs.
### Magic Numbers
```ts
export const SUBGRAPH_INPUT_ID = -10 // constants.ts:8
export const SUBGRAPH_OUTPUT_ID = -20 // constants.ts:11
```
Negative sentinel values in the ID space. Links check `origin_id === SUBGRAPH_INPUT_ID` to determine if they cross a subgraph boundary — a special case baked into the general-purpose `LLink` class.
### No Independent Widget or Slot IDs
**Widgets** are identified by `name + parent node`. Code searches by name in multiple places:
- `LGraphNode.ts:904``this.inputs.find((i) => i.widget?.name === w.name)`
- `LGraphNode.ts:4077``slot.widget.name === widget.name`
- `LGraphNode.ts:4086``this.widgets?.find((w) => w.name === slot.widget.name)`
If a widget is renamed, all these lookups silently break.
**Slots** are identified by their array index on the parent node. The serialized link format (`SerialisedLLinkArray`) stores slot indices:
```ts
type SerialisedLLinkArray = [
id,
origin_id,
origin_slot,
target_id,
target_slot,
type
]
```
If slots are reordered (e.g., by an extension adding a slot), all links referencing that node become stale.
### 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.
## 5. Law of Demeter Violations
Entities routinely reach through their container to access internal state and sibling entities.
### Nodes Reaching Into Graph Internals
8+ locations in `LGraphNode` access the graph's private `_links` map directly:
- Line 877: `this.graph._links.get(input.link)`
- Line 891: `this.graph._links.get(linkId)`
- Line 1254: `const link_info = this.graph._links.get(input.link)`
Nodes also reach through the graph to access sibling nodes' slots:
- Line 1150: `this.graph.getNodeById(link.origin_id)` → read origin's outputs
- Line 1342: `this.graph.getNodeById(link.target_id)` → read target's inputs
- Line 1556: `node.inputs[link_info.target_slot]` (accessing a sibling's slot by index)
### Canvas Mutating Graph Internals
`LGraphCanvas` directly increments the graph's version counter:
- Line 3084: `node.graph._version++`
- Line 7880: `node.graph._version++`
The canvas also reaches through nodes to their container:
- Line 8337: `node.graph.remove(node)` — canvas deletes a node by reaching through the node to its graph
### Entities Mutating Container State
`LGraphNode` directly mutates `graph._version++` from 8+ locations (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567). There is no encapsulated method for signaling a version change — every call site manually increments the counter.
## 6. Scattered Side Effects
### Version Counter
`graph._version` is incremented from **15+ locations** across three files:
| File | Locations |
| ----------------- | --------------------------------------------------- |
| `LGraph.ts` | Lines 956, 989, 1042, 1109, 2643 |
| `LGraphNode.ts` | Lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567 |
| `LGraphCanvas.ts` | Lines 3084, 7880 |
No central mechanism exists. It's easy to forget an increment (stale render) or add a redundant one (wasted work).
### Module-Scope Store Access
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
- `LLink.ts:24``const layoutMutations = useLayoutMutations()` (module scope)
- `Reroute.ts` — same pattern at module scope
- `BaseWidget.ts:20-22` — imports `usePromotionStore` and `useWidgetValueStore`
These make the domain objects untestable without a Vue app context.
### Change Notification Sprawl
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from
**12+ locations** in `LGraphCanvas` alone (lines 1574, 1592, 1604, 1620, 1752,
1770, 8754, 8760, 8771, 8777, 8803, 8811). These calls are grouping brackets:
misplaced or missing pairs can split one logical operation across multiple undo
entries, while unmatched extra calls can delay checkpoint emission until the
nesting counter returns to zero.
## 7. Render-Time Mutations
The render pass is not pure — it mutates state as a side effect:
| Location | Mutation |
| ----------------------------------- | ------------------------------------------------------------------- |
| `LGraphCanvas.drawNode()` line 5562 | `node._setConcreteSlots()` — rebuilds concrete slot arrays |
| `LGraphCanvas.drawNode()` line 5564 | `node.arrange()` — recalculates widget positions and sizes |
| `BaseWidget.getOutlineColor()` | Queries `PromotionStore` on every frame |
| Link rendering | Caches `_pos` center point and `_centreAngle` on the LLink instance |
This means:
- Rendering order matters (later nodes see side effects from earlier nodes)
- Performance profiling conflates render cost with layout cost
- Concurrent or partial renders would produce inconsistent state
## 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 |

View File

@@ -0,0 +1,196 @@
"""
Generate pixel art inventory icons for the Architecture Adventure game.
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
Skips icons that already exist on disk.
Usage: python docs/architecture/generate-icons.py
"""
import json
import os
import time
import urllib.request
import urllib.error
COMFY_URL = "http://localhost:8188"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ARTIFACT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-icon-prompts.json")
CHOICE_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-choice-icon-prompts.json")
ACHIEVEMENT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-achievement-icon-prompts.json")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "icons")
BASE_SEED = 7777
WIDTH = 128
HEIGHT = 128
def build_workflow(prompt_text, seed, prefix):
return {
"1": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
"weight_dtype": "default",
},
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "lumina2",
"device": "default",
},
},
"3": {
"class_type": "VAELoader",
"inputs": {"vae_name": "ae.safetensors"},
},
"4": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {"shift": 3, "model": ["1", 0]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt_text, "clip": ["2", 0]},
},
"7": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
"8": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
},
"9": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"control_after_generate": "fixed",
"steps": 8,
"cfg": 1,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["8", 0],
},
},
"10": {
"class_type": "VAEDecode",
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
},
"11": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
},
}
def submit_prompt(workflow):
payload = json.dumps({"prompt": workflow}).encode("utf-8")
req = urllib.request.Request(
f"{COMFY_URL}/prompt",
data=payload,
headers={"Content-Type": "application/json"},
)
try:
resp = urllib.request.urlopen(req)
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
raise RuntimeError(f"HTTP {e.code}: {body}")
def poll_history(prompt_id, timeout=120):
start = time.time()
while time.time() - start < timeout:
try:
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
data = json.loads(resp.read())
if prompt_id in data:
return data[prompt_id]
except Exception:
pass
time.sleep(2)
return None
def download_image(filename, subfolder, dest_path):
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
urllib.request.urlretrieve(url, dest_path)
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Collect all icons from both prompt files
all_icons = {}
with open(ARTIFACT_PROMPTS) as f:
data = json.load(f)
for icon_id, entry in data["artifacts"].items():
all_icons[icon_id] = entry["prompt"]
if os.path.exists(CHOICE_PROMPTS):
with open(CHOICE_PROMPTS) as f:
data = json.load(f)
for icon_id, entry in data["choices"].items():
all_icons[icon_id] = entry["prompt"]
if os.path.exists(ACHIEVEMENT_PROMPTS):
with open(ACHIEVEMENT_PROMPTS) as f:
data = json.load(f)
for icon_id, entry in data["achievements"].items():
all_icons[f"ending-{icon_id}"] = entry["prompt"]
# Filter out already-generated icons
to_generate = {}
for icon_id, prompt in all_icons.items():
dest = os.path.join(OUTPUT_DIR, f"{icon_id}.png")
if os.path.exists(dest):
print(f" Skipping {icon_id}.png (already exists)")
else:
to_generate[icon_id] = prompt
if not to_generate:
print("All icons already generated. Nothing to do.")
return
# Submit jobs
jobs = []
for i, (icon_id, prompt) in enumerate(to_generate.items()):
prefix = f"adventure-icons/{icon_id}"
wf = build_workflow(prompt, BASE_SEED + i, prefix)
result = submit_prompt(wf)
prompt_id = result["prompt_id"]
jobs.append((icon_id, prompt_id))
print(f" Submitted: {icon_id} -> {prompt_id}")
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
# Poll for completion
completed = set()
while len(completed) < len(jobs):
for icon_id, prompt_id in jobs:
if prompt_id in completed:
continue
history = poll_history(prompt_id, timeout=5)
if history:
completed.add(prompt_id)
outputs = history.get("outputs", {})
for node_out in outputs.values():
for img in node_out.get("images", []):
src_filename = img["filename"]
subfolder = img.get("subfolder", "")
dest = os.path.join(OUTPUT_DIR, f"{icon_id}.png")
download_image(src_filename, subfolder, dest)
print(f" [{len(completed)}/{len(jobs)}] {icon_id}.png downloaded")
if len(completed) < len(jobs):
time.sleep(2)
print(f"\nDone! {len(completed)} icons saved to {OUTPUT_DIR}/")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,165 @@
"""
Generate pixel art room images for the Architecture Adventure game.
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
Usage: python docs/architecture/generate-images.py
"""
import json
import os
import time
import urllib.request
COMFY_URL = "http://localhost:8188"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROMPTS_FILE = os.path.join(SCRIPT_DIR, "adventure-image-prompts.json")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "images")
BASE_SEED = 2024
WIDTH = 1152
HEIGHT = 640
def build_workflow(prompt_text, seed, prefix):
return {
"1": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
"weight_dtype": "default",
},
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "lumina2",
"device": "default",
},
},
"3": {
"class_type": "VAELoader",
"inputs": {"vae_name": "ae.safetensors"},
},
"4": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {"shift": 3, "model": ["1", 0]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt_text, "clip": ["2", 0]},
},
"7": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
"8": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
},
"9": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"control_after_generate": "fixed",
"steps": 8,
"cfg": 1,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["8", 0],
},
},
"10": {
"class_type": "VAEDecode",
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
},
"11": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
},
}
def submit_prompt(workflow):
payload = json.dumps({"prompt": workflow}).encode("utf-8")
req = urllib.request.Request(
f"{COMFY_URL}/prompt",
data=payload,
headers={"Content-Type": "application/json"},
)
try:
resp = urllib.request.urlopen(req)
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
raise RuntimeError(f"HTTP {e.code}: {body}")
def poll_history(prompt_id, timeout=120):
start = time.time()
while time.time() - start < timeout:
try:
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
data = json.loads(resp.read())
if prompt_id in data:
return data[prompt_id]
except Exception:
pass
time.sleep(2)
return None
def download_image(filename, subfolder, dest_path):
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
urllib.request.urlretrieve(url, dest_path)
def main():
with open(PROMPTS_FILE) as f:
data = json.load(f)
rooms = data["rooms"]
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Submit all jobs
jobs = []
for i, (room_id, room) in enumerate(rooms.items()):
prefix = f"adventure/{room_id}"
wf = build_workflow(room["prompt"], BASE_SEED + i, prefix)
result = submit_prompt(wf)
prompt_id = result["prompt_id"]
jobs.append((room_id, prompt_id, prefix))
print(f" Submitted: {room_id} -> {prompt_id}")
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
# Poll for completion
completed = set()
while len(completed) < len(jobs):
for room_id, prompt_id, prefix in jobs:
if prompt_id in completed:
continue
history = poll_history(prompt_id, timeout=5)
if history:
completed.add(prompt_id)
# Find output filename
outputs = history.get("outputs", {})
for node_id, node_out in outputs.items():
images = node_out.get("images", [])
for img in images:
src_filename = img["filename"]
subfolder = img.get("subfolder", "")
dest = os.path.join(OUTPUT_DIR, f"{room_id}.png")
download_image(src_filename, subfolder, dest)
print(f" [{len(completed)}/{len(jobs)}] {room_id}.png downloaded")
if len(completed) < len(jobs):
time.sleep(2)
print(f"\nDone! {len(completed)} images saved to {OUTPUT_DIR}/")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More