Compare commits

...

7 Commits

Author SHA1 Message Date
GitHub Action
1c7f65ca72 [automated] Apply ESLint and Oxfmt fixes 2026-03-17 14:24:27 +00:00
bymyself
afb2a47bc2 fix: fix event listener leak in SiteNav and add security headers
- Extract astro:after-swap handler to named function so it can be
  removed in onUnmounted (fixes event listener leak)
- Add vercel.json with security headers (X-Frame-Options, nosniff,
  Referrer-Policy, Permissions-Policy)

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10142#pullrequestreview-2893455098
2026-03-17 07:17:40 -07:00
bymyself
b2e7eb39da fix: improve a11y — add aria-labels, Escape handler, v-show menu, favicon 2026-03-17 07:17:40 -07:00
bymyself
c7ce889b59 fix: hide template selector after shared workflow accept (#9913)
## Summary

Hide the template selector when a first-time cloud user accepts a shared
workflow from a share link, so the shared workflow opens without the
onboarding template dialog lingering.

## Changes

- **What**: Added shared-workflow loader behavior to close the global
template selector on accept actions (`copy-and-open` and `open-only`)
while keeping cancel behavior unchanged.
- **What**: Added targeted unit tests covering hide-on-accept and
no-hide-on-cancel behavior in the shared workflow URL loader.

## Review Focus

Confirm that share-link accept paths now dismiss the template selector
and that cancel still leaves it available.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9913-fix-hide-template-selector-after-shared-workflow-accept-3236d73d365081099c04e350d499fad2)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>

fix: restore native copy/paste events for image paste support (#9914)

## Summary

- Remove Ctrl+C and Ctrl+V keybindings from the keybinding service
defaults so native browser copy/paste events fire
- This restores image paste into LoadImage nodes, which broke after
#9459

## Problem

PR #9459 moved Ctrl+C/V into the keybinding service, which calls
`event.preventDefault()` on keydown. This prevents the browser `paste`
event from firing, so `usePaste` (which detects images in the clipboard)
never runs. The `PasteFromClipboard` command only reads from
localStorage, completely bypassing image detection.

**Repro:** Copy a node → copy an image externally → try to paste the
image into a LoadImage node → gets old node data from localStorage
instead.

## Fix

Remove Ctrl+C and Ctrl+V from `CORE_KEYBINDINGS` in `defaults.ts`. The
native browser events now fire as before, and `useCopy`/`usePaste`
handle them correctly. Ctrl+Shift+V, Ctrl+A, Delete, and Backspace
keybindings remain in the keybinding service.

Fixes #9459 (regression)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9914-fix-restore-native-copy-paste-events-for-image-paste-support-3236d73d365081c7ac53f983f316e10f)
by [Unito](https://www.unito.io)

fix: clear stale widget slotMetadata on link disconnect (#9885)

## Summary
Fixes text field becoming non-editable when a previously linked input is
removed from a custom node.

## Problem
When a widget's input was promoted to a slot, connected via a link, and
then the input was removed (e.g., by updating the custom node
definition), the widget retained stale `slotMetadata` with `linked:
true`. This prevented the widget from being editable.

## Solution
In `refreshNodeSlots`, removed the `if (slotInfo)` guard so
`widget.slotMetadata` is always assigned — either to valid metadata or
`undefined`. This ensures stale linked state is cleared when inputs no
longer match widgets.

## Acceptance Criteria
1. Text field remains editable after promote→connect→disconnect cycle
2. Text field returns to editable state when noodle disconnected
3. No mode switching needed to restore editability

## Testing
- Added regression test: "clears stale slotMetadata when input no longer
matches widget"
- All existing tests pass (18/18 in affected file)

---
**Note: This PR currently contains only the RED (failing test) commit
for TDD verification. The GREEN (fix) commit will be pushed after CI
confirms the test failure.**

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9885-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3226d73d365081269319c027b42d9f6b)
by [Unito](https://www.unito.io)

fix: stabilize subgraph promoted widget identity and rendering (#9896)

## Summary

Fix subgraph promoted widget identity/rendering so on-node widgets stay
correct through configure/hydration churn, duplicate names, and
linked+independent coexistence.

## Changes

- **Subgraph promotion reconciliation**: stabilize linked-entry identity
by subgraph slot id, preserve deterministic linked representative
selection, and prune stale alias/fallback entries without dropping
legitimate independent promotions.
- **Promoted view resolution**: bind slot mapping by promoted view
object identity (`getSlotFromWidget` / `getWidgetFromSlot`) to avoid
same-name collisions.
- **On-node widget rendering**: harden `NodeWidgets` identity and dedup
to avoid visual aliasing, prefer visible duplicates over hidden stale
entries, include type/source execution identity, and avoid collapsing
transient unresolved entries.
- **Mapping correctness**: update `useGraphNodeManager` promoted source
mapping to resolve by input target only when the promoted view is
actually bound to that input.
- **Subgraph input uniqueness**: ensure empty-slot promotion creates
unique input names (`seed`, `seed_1`, etc.) for same-name multi-source
promotions.
- **Safety fix**: guard against undefined canvas in slot-link
interaction.
- **Tests/fixtures**: add focused regressions for fixture path
`subgraph_complex_promotion_1`, linked+independent same-name cases,
duplicate-name identity mapping, dedup behavior, and input-name
uniqueness.

## Review Focus

Validate behavior around transient configure/hydration states (`-1` id
to concrete id), duplicate-name promotions, linked representative
recovery, and that dedup never hides legitimate widgets while still
removing true duplicates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9896-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3226d73d365081c8a1e8d0a5a22e826d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>

1.42.5 (#9906)

Patch version increment to 1.42.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>

fix: skip redundant appScalePercentage updates during zoom/pan (#9403)

## What
Add equality check before updating `appScalePercentage` reactive ref.

## Why
Firefox profiler shows 586 `setElementText` markers from continuous text
interpolation updates during zoom/pan. The rounded percentage value
often doesn't change between events.

## How
Extract `updateAppScalePercentage()` helper with equality guard —
compares new rounded value to current before assigning to the ref.

## Perf Impact
Expected: eliminates ~90% of `setElementText` markers during zoom/pan

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9403-fix-skip-redundant-appScalePercentage-updates-during-zoom-pan-31a6d73d3650812db8f2d68ac73c95b0)
by [Unito](https://www.unito.io)

test: add browser test for textarea right-click context menu in subgraph (#9891)

## Summary

Add E2E test coverage for the textarea widget right-click context menu
inside subgraphs.

The fix was shipped in #9840 — this PR adds the missing browser test.

## Test

- Loads a subgraph workflow with a CLIPTextEncode (textarea) node
- Navigates into the subgraph
- Right-clicks the textarea DOM element
- Asserts that the ComfyUI "Promote Widget" context menu option appears

## Related

- Fixes the test gap from #9840
- Notion ticket: d7a53160-e1e1-42bb-a5ac-c0c2702c629c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9891-test-add-browser-test-for-textarea-right-click-context-menu-in-subgraph-3226d73d365081a4be51f89b5d505361)
by [Unito](https://www.unito.io)

feat: expand CDP perf metrics — add DOM nodes, script duration, event listeners (#9887)

## Summary

Expands the performance testing infrastructure to collect 4 additional
CDP metrics that are already returned by `Performance.getMetrics` but
were not being read. This is a zero-cost expansion — no additional CDP
calls, just reading more fields from the existing response.

## New Metrics

| Metric | CDP Source | What It Detects |
|---|---|---|
| `domNodes` | `Nodes` | DOM node count delta — widget DOM leaks during
node create/destroy |
| `jsHeapTotalBytes` | `JSHeapTotalSize` | Total heap delta — combined
with `heapDeltaBytes` shows GC pressure |
| `scriptDurationMs` | `ScriptDuration` | JS execution time vs total
task time — script vs rendering balance |
| `eventListeners` | `JSEventListeners` | Listener count delta — detects
listener accumulation across lifecycle |

## Changes

### `browser_tests/fixtures/helpers/PerformanceHelper.ts`
- Added 4 fields to `PerfSnapshot` interface
- Added 4 fields to `PerfMeasurement` interface
- Wired through `getSnapshot()` and `stopMeasuring()`

### `scripts/perf-report.ts`
- Added 4 fields to `PerfMeasurement` interface
- Expanded `MetricKey` type and `REPORTED_METRICS` array with 3 new
reported metrics (`domNodes`, `scriptDurationMs`, `eventListeners`)
- `jsHeapTotalBytes` is collected but not in `REPORTED_METRICS` — it's
used alongside `heapDeltaBytes` for GC pressure ratio analysis

## Why These 4

From a gap analysis of all ~30 CDP metrics, these were identified as
highest priority for ComfyUI:
- **`Nodes`** (P0): ComfyUI dynamically creates/destroys widget DOM. DOM
bloat from leaked widgets is a key performance risk, especially for Vue
Nodes 2.0.
- **`ScriptDuration`** (P1): Separates JS execution from layout/paint.
Reveals whether perf issues are script-heavy or rendering-heavy.
- **`JSEventListeners`** (P1): Widget lifecycle can leak listeners
across node add/remove cycles.
- **`JSHeapTotalSize`** (P1): With `JSHeapUsedSize`, the ratio shows GC
fragmentation pressure.

## Backward Compatibility

The `PerfMeasurement` interface is extended (not changed). Old baseline
`perf-metrics.json` files without these fields will have `undefined`
values, which the report script handles gracefully (shows `—` for
missing data).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9887-feat-expand-CDP-perf-metrics-add-DOM-nodes-script-duration-event-listeners-3226d73d3650818abea1d4a441667c38)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>

fix: prevent white flash when opening mask editor (#9860)

## Summary

- Remove hardcoded `bg-white` from mask editor canvas background div to
prevent white flash on dialog open
- Add a loading spinner while the mask editor initializes (image
loading, canvas setup, GPU resources)
- Background color is set dynamically by `setCanvasBackground()` after
initialization

Fixes #9852

### AS IS

https://github.com/user-attachments/assets/7da61e32-671b-4056-b5ec-8cb246fc7689

### TO BE

https://github.com/user-attachments/assets/bfdedc69-f690-42c5-8591-619623c04f55

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9860-fix-prevent-white-flash-when-opening-mask-editor-3226d73d365081de9b7ad4622438e6ed)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

fix: prevent live preview dimension flicker between frames (#9937)

## Summary

Fix "Calculating dimensions" text flickering during live sampling
preview in Vue renderer.

## Changes

- **What**: Stop resetting `actualDimensions` to `null` on every
`imageUrl` change. Previous dimensions are retained while the new frame
loads, eliminating the flicker. Error state is still reset correctly.

## Review Focus

The watcher on `props.imageUrl` previously reset both `actualDimensions`
and `imageError`. Now it only resets `imageError`, since
`handleImageLoad` updates dimensions when the new frame actually loads.
This means stale dimensions show briefly between frames, which is
intentionally better than showing "Calculating dimensions" text.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9937-fix-prevent-live-preview-dimension-flicker-between-frames-3246d73d36508154a676e5996112354f)
by [Unito](https://www.unito.io)

feat: make Vue nodes (Nodes 2.0) default for new desktop installs (#9947)

## What

Makes Vue nodes (Nodes 2.0) the default renderer for new desktop app
installs (version ≥1.41.0), matching the behavior already live for cloud
new installs.

## Why

Step 2 of the Nodes 2.0 rollout sequence:
1.  Cloud new installs (≥1.41.0) — DONE
2. 👉 **Desktop app (new installs)** — this PR
3.  Local installs
4.  Remove Beta tag
5.  GTM announcement

No forced migration — only changes the default for new installs.
Existing users keep their setting. Rollback is a settings flip.

## Change

In `coreSettings.ts`, the `defaultsByInstallVersion` for
`Comfy.VueNodes.Enabled` changes from:
```typescript
defaultsByInstallVersion: { '1.41.0': isCloud },
```
to:
```typescript
defaultsByInstallVersion: { '1.41.0': isCloud || isDesktop },
```

## Gated on

- M2 perf target (≥52 FPS on 245-node workflow) — layer merge landed,
likely met
- M-DevRel migration docs (blocks Beta tag removal, not this flip)

Draft PR — ceremonial, to be merged when M2 checkpoint passes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9947-feat-make-Vue-nodes-Nodes-2-0-default-for-new-desktop-installs-3246d73d365081b280dfff932c7aa016)
by [Unito](https://www.unito.io)

fix: fix perf CI pipeline — z-score baselines, force-push staleness, baseline storage (#9886)

## Summary

Fixes three critical issues with the CI performance reporting pipeline
that made perf reports useless on PRs (demonstrated by PR #9248 — deep
watcher removal merged without useful perf signal).

## Changes

### 1. Fix z-score baseline variance collection (`0/5 runs`)
**Root cause:** PR #9305 added z-score statistical analysis code to
`perf-report.ts`, but the historical data download step was placed in
the wrong workflow file. The report is generated in
`pr-perf-report.yaml` (a `workflow_run`-triggered job), but the
historical download was in `ci-perf-report.yaml` (the test runner) —
different runners, different filesystems.

**Fix:** Implement `perf-data` orphan branch storage:
- On push to main: save `perf-metrics.json` to `perf-data` branch with
timestamped filename
- On PR report: fetch last 5 baselines from `perf-data` branch into
`temp/perf-history/`
- Rolling window of 20 baselines, oldest pruned automatically
- Same pattern used by `github-action-benchmark` (33.7k repos)

### 2. Fix force-push comment staleness
**Root cause:** `cancel-in-progress: true` kills the perf test run
before it uploads artifacts. The downstream report workflow only
triggers on `conclusion == 'success'` — cancelled runs are ignored, so
the comment from the first successful run goes stale.

**Fix:**
- Change `cancel-in-progress: false` — with GitHub's queue depth of 1,
rapid pushes (A,B,C,D) run A and D, skipping B and C
- Add SHA validation in `pr-perf-report.yaml` — before posting, check if
the workflow_run's head SHA still matches the PR's current head. Skip
posting stale results.

### 3. Add permissions for baseline operations
- `contents: write` on CI job (needed for pushing to perf-data branch)
- `actions: read` on both workflows (needed for artifact/baseline
access)

## One-time setup required
After merging, create the `perf-data` orphan branch:
```bash
git checkout --orphan perf-data
git rm -rf .
echo '# Performance Baselines' > README.md
mkdir -p baselines
git add README.md baselines
git commit -m 'Initialize perf-data branch'
git push origin perf-data
```

The first 2 pushes to main after setup will build up variance data, and
z-scores will start appearing in PR reports (threshold is
`historical.length >= 2`).

## Testing
- YAML validated with `yaml.safe_load()`
- `perf-report.ts` `loadHistoricalReports()` already reads from
`temp/perf-history/<index>/perf-metrics.json` — no code changes needed
- All new steps use `continue-on-error: true` for graceful degradation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9886-fix-fix-perf-CI-pipeline-z-score-baselines-force-push-staleness-baseline-storage-3226d73d365081538424c7945e71f308)
by [Unito](https://www.unito.io)

draft: add red-green-fix skill for verified bug fix workflow (#9954)

## Summary

- Add a Claude Code skill (`/red-green-fix`) that enforces the red-green
commit pattern for bug fixes
- Ensures a failing test is committed first (red CI), then the fix is
committed separately (green CI)
- Gives reviewers proof that the test actually catches the bug
- Includes `reference/testing-anti-patterns.md` with common mistakes
contextualized to this codebase

## Structure

```
.claude/skills/red-green-fix/
├── SKILL.md                              # Main skill definition
└── reference/
    └── testing-anti-patterns.md          # Anti-patterns guide
```

## Test Plan

- [ ] Invoke `/red-green-fix <bug description>` in Claude Code and
verify the two-step workflow
- [ ] Confirm PR template includes red-green verification table
- [ ] Review anti-patterns reference for completeness

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9954-draft-add-red-green-fix-skill-for-verified-bug-fix-workflow-3246d73d365081339a83dc09263b0f33)
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>

test: add large-graph perf test with 245-node workflow (backlog N5) (#9940)

## What

Adds a 245-node workflow asset and two `@perf` tests to establish a
baseline for large-graph performance regressions (Tier 6 in the
performance backlog).

## Why

Backlog item N5: we need CI regression detection for compositor layer
management, GPU texture count, and transform pane cost at 245+ nodes.
This is PR1 of 2 — establishes baseline metrics on main. Future
optimization PRs will show improvement deltas against this baseline.

## Tests Added

- **`large graph idle rendering`** — 120 frames idle with 245 nodes,
measures style recalcs, layouts, task duration, heap delta
- **`large graph pan interaction`** — middle-click pan across 245 nodes,
stresses compositor layer management and transform recalculation

## Workflow Asset

`browser_tests/assets/large-graph-workflow.json` — 245 nodes (49
pipelines of CheckpointLoader → 2× CLIPTextEncode → KSampler +
EmptyLatentImage), 294 links. Minimal structure focused on node count.

## Verification

- [x] `pnpm typecheck:browser` passes
- [x] `pnpm lint` passes (eslint on changed file)
- [x] All link references in JSON validated programmatically

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9940-test-add-large-graph-perf-test-with-245-node-workflow-backlog-N5-3246d73d365081f6b5d8ddb9a85e6ad0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>

feat: add Ingest API codegen with Zod schema generation (#9932)

## Summary

- Add `packages/ingest-types/` package that auto-generates TypeScript
types and Zod schemas from the Ingest service OpenAPI spec
- Uses `@hey-api/openapi-ts` with built-in Zod plugin (Zod v3
compatible)
- Filters out overlapping endpoints shared with the local ComfyUI Python
backend
- Generates **493 TypeScript types** and **256 Zod schemas** covering
cloud-only endpoints
- Configure knip to ignore generated files

## CI automation

The cloud repo pushes generated types to this repo (push model, no
private repo cloning).
See: Comfy-Org/cloud#2858

## How endpoint filtering works

Codegen targets are controlled by the **exclude list** in
`packages/ingest-types/openapi-ts.config.ts`. Everything in the Ingest
`openapi.yaml` is included **except** overlapping endpoints that also
exist in the local ComfyUI Python backend:

**Excluded (overlapping with ComfyUI Python):**
`/prompt`, `/queue`, `/history`, `/object_info`, `/features`,
`/settings`, `/system_stats`, `/interrupt`, `/upload/*`, `/view`,
`/jobs`, `/userdata`, `/webhooks/*`, `/internal/*`

**Included (cloud-only, codegen targets):**
`/workspaces/*`, `/billing/*`, `/secrets/*`, `/assets/*`, `/tasks/*`,
`/auth/*`, `/workflows/*`, `/workspace/*`, `/user`, `/settings/{key}`,
`/tags`, `/feedback`, `/invite_code/*`, `/experiment/models/*`,
`/global_subgraphs/*`

## Follow-up: replace manual types with generated ones

This PR only sets up the codegen infrastructure. A follow-up PR should
replace manually maintained types with imports from
`@comfyorg/ingest-types`:

| File | Lines | Current | Replace with |
|------|-------|---------|-------------|
| `src/platform/workspace/api/workspaceApi.ts` | ~270 | TS interfaces |
`import type { ... } from '@comfyorg/ingest-types'` |
| `src/platform/secrets/types.ts` | ~32 | TS interfaces | `import type {
... } from '@comfyorg/ingest-types'` |
| `src/platform/assets/schemas/assetSchema.ts` | ~125 | Zod schemas |
`import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/assets/schemas/mediaAssetSchema.ts` | ~50 | Zod schemas
| `import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/tasks/services/taskService.ts` | ~70 | Zod schemas |
`import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/workspace/workspaceTypes.ts` | ~6 | TS interface |
`export type { ... } from '@comfyorg/ingest-types'` |

## Test plan

- [x] `pnpm generate` in `packages/ingest-types/` produces
`types.gen.ts` and `zod.gen.ts`
- [x] `pnpm typecheck` passes
- [x] Pre-commit hooks pass (lint, typecheck, format)
- [x] Generated Zod schemas validate correct data and reject invalid
data
- [x] No import conflicts with existing code (generated types are
isolated in separate package)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>

feat: surface missing models in Error Tab for OSS and remove legacy dialog (#9921)

## Summary
- Surface missing models in the Error Tab for OSS environments,
replacing the legacy modal dialog
- Add Download button per model and Download All button in group header
with file size display
- Move download business logic from `components/dialog/content` to
`platform/missingModel`
- Remove legacy missing models dialog components and composable

## Changes
- **Pipeline**: Remove `isCloud` guard from `scanAllModelCandidates` and
`surfaceMissingModels` so OSS detects missing models
- **Grouping**: Group non-asset-supported models by directory in OSS
instead of lumping into UNSUPPORTED
- **UI**: Add Download button (matching Install Node Pack design) and
Download All header button
- **Store**: Add `folderPaths`/`fileSizes` state with setter methods,
race condition guard
- **Cleanup**: Delete `MissingModelsContent`, `MissingModelsHeader`,
`MissingModelsFooter`, `useMissingModelsDialog`, `missingModelsUtils`
- **Tests**: Add OSS/Cloud grouping tests, migrate Playwright E2E to
Error Tab, improve test isolation
- **Snapshots**: Reset Playwright screenshot expectations since OSS
missing model error detection now causes red highlights on affected
nodes
- **Accessibility**: Add `aria-label` with model name, `aria-expanded`
on toggle, warning icon for unknown category

## Test plan
- [x] Unit tests pass (86 tests)
- [x] TypeScript typecheck passes
- [x] knip passes
- [x] Load workflow with missing models in OSS → Error Tab shows missing
models grouped by directory
- [x] Download button triggers browser download with correct URL
- [x] Download All button downloads all downloadable models
- [x] Cloud environment behavior unchanged
- [x] Playwright E2E: `pnpm test:browser:local -- --grep "Missing models
in Error Tab"`

## Screenshots

https://github.com/user-attachments/assets/12f15e09-215a-4c58-87ed-39bbffd1359c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9921-feat-surface-missing-models-in-Error-Tab-for-OSS-and-remove-legacy-dialog-3236d73d365081f0a9dfc291978f5ecf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>

fix: cloud subscribe redirect hangs waiting for billing init (#9965)

## Summary

Fix /cloud/subscribe route hanging indefinitely because billing context
never initializes during the onboarding flow.

## Changes

- **What**: Replace passive `await until(isInitialized).toBe(true)` with
explicit `await initialize()` in CloudSubscriptionRedirectView. Remove
unused `until` import.

![Kapture 2026-03-15 at 23 16
22](https://github.com/user-attachments/assets/0a12487b-b39a-4f96-9a4c-96a01facfdd8)

## Review Focus

In the onboarding flow, `useTeamWorkspaceStore().activeWorkspace` is not
set, so `useBillingContext`'s internal watch (which triggers
`initialize()` on workspace change) enters the `!newWorkspaceId` branch
— it resets `isInitialized` to `false` and returns without ever calling
`initialize()`. The old code then awaited `isInitialized` becoming
`true` forever.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9965-fix-cloud-subscribe-redirect-hangs-waiting-for-billing-init-3246d73d3650812d93ebd477c544fa0a)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>

feat: add TBT/frameDuration metrics and new perf test scenarios (#9910)

## Summary

Adds Total Blocking Time (TBT) and frame duration metrics to the
performance testing infrastructure, plus three new test scenarios
covering zoom, pan, and many-nodes-idle.

## Changes

### New Metrics
- **`totalBlockingTimeMs`** — Computed from PerformanceObserver
`longtask` entries: `sum(duration - 50ms)` for tasks >50ms. Measures
main thread blocking.
- **`frameDurationMs`** — Average frame duration via rAF timing (16.67ms
= 60fps target). Measures rendering smoothness.

### New Test Scenarios
| Scenario | Description |
|---|---|
| `canvas-zoom-sweep` | 10 zoom-in + 10 zoom-out cycles on default
workflow |
| `canvas-pan-many-nodes` | 10 pan sweeps over 100-node workflow |
| `canvas-many-nodes-idle` | 2-second idle measurement with 100 nodes
rendered |

### Infrastructure
- `PerformanceHelper.ts`: Installs PerformanceObserver for longtask,
collects TBT, measures frame duration via rAF
- `perf-report.ts`: Reports TBT and frame duration in PR comment tables
- `browser_tests/assets/perf/many_nodes_100.json`: 100-node (10×10 grid)
test fixture

## Review Focus
- TBT collection clears entries at `startMeasuring()` and reads at
`stopMeasuring()` — ensure no race with observer buffering
- Frame duration sampling uses 10 frames — enough for signal without
slowing tests

Depends on: #9886, #9887

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9910-feat-add-TBT-frameDuration-metrics-and-new-perf-test-scenarios-3236d73d365081488ae3c594a8bf7cff)
by [Unito](https://www.unito.io)

fix: LGraphGroup paste position (#9962)

## Summary

Fix group paste position: groups now paste at the cursor location
instead of on top of the original.

## Changes

- **What**: Added LGraphGroup offset handling in _deserializeItems()
position adjustment loop, matching existing LGraphNode and Reroute
behavior.

## Screenshots

Before:

https://github.com/user-attachments/assets/e317af10-8009-4092-9d14-de79316cd853

After:

https://github.com/user-attachments/assets/f4ffefd5-519a-4592-812c-c88e3b5940fd

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9962-fix-LGraphGroup-paste-position-3246d73d365081eea5b2e2507da861de)
by [Unito](https://www.unito.io)

fix: tree explorer nodes not filling parent container width (#9964)

## Summary

Fix tree explorer nodes not filling the full width of the sidebar
container, causing text to overflow instead of truncating.

## Changes

- **What**: Add `min-w-0` to `TreeRoot` to allow flex shrinking within
sidebar. Add `w-full` and `min-w-0` to tree node rows so
absolutely-positioned virtualizer items fill the container width and
text truncates correctly.
<img width="365" height="749" alt="image"
src="https://github.com/user-attachments/assets/320910f3-52ad-4634-a935-6bd1a40aea7f"
/>

## Review Focus

The virtualizer renders each item with `position: absolute; left: 0` but
no explicit width, so rows would size to content rather than filling the
container. Adding `w-full` ensures rows stretch to 100% of the
virtualizer container, and `min-w-0` allows proper flex shrinking for
deep indentation levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9964-fix-tree-explorer-nodes-not-filling-parent-container-width-3246d73d36508138be38fdcac15ae4ef)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>

feat: add Copy URL button to missing model rows for OSS (#9966)

1.42.6 (#9986)

Patch version increment to 1.42.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9986-1-42-6-3256d73d365081a28bfad82022ce3440)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>

fix: block missing e2e regression coverage in CodeRabbit (#9987)

## Summary

Make the CodeRabbit end-to-end regression coverage check actually block
fix-like PRs until it is resolved or explicitly overridden by a
requested reviewer, and harden the prompt so it evaluates only PR-local
metadata.

## Changes

- **What**: Set the `End-to-end regression coverage for fixes` custom
check mode from `warning` to `error`
- **What**: Enable `reviews.request_changes_workflow` so CodeRabbit can
block on failed `error` pre-merge checks
- **What**: Set
`reviews.pre_merge_checks.override_requested_reviewers_only` to `true`
so only requested reviewers can bypass a failed check
- **What**: Tighten the custom check instructions to use only PR
metadata in review context, avoid shell commands, and avoid reverse-diff
or base-branch file evaluation

## Review Focus

Confirm this is the intended CodeRabbit enforcement model for missing
Playwright regression coverage on fix-like PRs and that the prompt
wording is strict enough to avoid false positives from reversed diffs.

fix: add reve and elevenlabs to icon safelist (#9990)

## Summary

Reve and ElevenLabs provider icons were not displaying in the node
library because they were missing from the Tailwind icon safelist.

## Changes

- **What**: Add `reve` and `elevenlabs` to the `@source inline` safelist
in `style.css` so `icon-[comfy--reve]` and `icon-[comfy--elevenlabs]`
classes are generated. Add corresponding `PROVIDER_COLORS` entries in
`categoryUtil.ts`.

<img width="308" height="106" alt="image"
src="https://github.com/user-attachments/assets/d488898a-fbad-4af0-8921-0e8ee7d4705a"
/>
<img width="308" height="78" alt="image"
src="https://github.com/user-attachments/assets/2b3b7172-095b-415e-a49a-d303977e0abc"
/>

## Review Focus

The SVG files already existed in `packages/design-system/src/icons/` but
Tailwind's tree-shaking dropped the classes since they're only used
dynamically via `getProviderIcon()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9990-fix-add-reve-and-elevenlabs-to-icon-safelist-3256d73d36508105994fcdd5d0568027)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>

fix: mask editor save shows blank image in Load Image node (#9984)

## Summary

Mask editor save was showing a blank image in the Load Image node
(legacy nodes mode, not Nodes 2.0) because
`updateNodeWithServerReferences` called `updateNodeImages`, which
silently no-ops when the node has no pre-existing execution outputs.
Replaced with `setNodeOutputs` which properly creates output entries
regardless of prior state.

**Affects:** Legacy nodes mode only. Nodes 2.0 (Vue Nodes) renders
images via Vue components and is not affected.

- Fixes #9983
- Fixes #9782
- Fixes #9952

## Red-Green Verification

| Commit | SHA | CI Status | Run | Purpose |
|--------|-----|-----------|-----|---------|
| `test: add failing test for mask editor save showing blank image` |
`0ab66e8` | 🔴
[Red](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23125427860)
| CI: Tests Unit **failure** | Proves the test catches the bug |
| `fix: mask editor save shows blank image in Load Image node` |
`564cc9c` | 🟢
[Green](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23127289891)
| CI: Tests Unit **success** | Proves the fix resolves the bug |

## manual testing

### as is

https://github.com/user-attachments/assets/8d5c36ce-2c5e-4609-b246-dcf896c4a8e7

### to be

https://github.com/user-attachments/assets/c8ae4f0e-3da0-40f2-a543-d1d5a6bce795

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [ ] E2E regression test not added: mask editor save requires canvas
pixel manipulation + server upload round-trip which is covered by the
existing unit test mocking the full `save()` flow. The Playwright test
infrastructure does not currently support mask editor interactions (draw
+ save).
- [x] Manual verification (legacy nodes mode): Load Image → upload →
mask editor → draw → save → verify image refreshes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: allow URL input for free tier users, gate on import button (#10024)

## Summary
- Remove free-tier restriction from the URL input field in
`MissingModelUrlInput.vue` so it is always editable
- Move the subscription check (`canImportModels`) to the Import button
click handler — free-tier users see the upgrade modal only when they
attempt to import
- Extract inline ternary to named `handleImportClick` method for clarity

## Test plan
- [x] Unit tests added (`MissingModelUrlInput.test.ts`) verifying:
  - URL input is always editable regardless of subscription tier
  - Import button calls `handleImport` for paid users
- Import button calls `showUploadDialog` (upgrade modal) for free-tier
users
- [x] Verify URL input is editable for free-tier users on cloud
- [x] Verify clicking Import as free-tier opens the subscription modal
- [x] Verify paid users can import normally without changes

## E2E test rationale
Playwright E2E regression tests are impractical for this change because
`MissingModelUrlInput` only renders when `isAssetSupported` is true,
which requires `isCloud` — a compile-time constant (`__DISTRIBUTION__`).
The OSS test build always sets `isCloud = false`, so the component never
renders in the E2E environment. Unit tests with mocked feature flags
provide equivalent behavioral coverage.

fix: prevent subscription UI from rendering on non-cloud distributions (#9958)

## Summary

Prevent Plans & Pricing dialog, subscription buttons, and cloud-only
menu items from appearing on desktop/localhost distributions.

## Changes

- **What**: Add `isCloud` guards to
`useSubscriptionDialog.showPricingTable`, `TopbarSubscribeButton`, and
`CurrentUserPopoverLegacy` so subscription UI only renders on cloud
- **Tests**: 24 tests across 3 test files (1 modified, 2 new) covering
cloud/non-cloud behavior

## Review Focus

- Guard placement in `CurrentUserPopoverLegacy.vue` — multiple `v-if`
conditions updated to include `isCloud`
- Early-return in `showPricingTable` as a defense-in-depth measure

Fixes COM-16820

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9958-fix-prevent-subscription-UI-from-rendering-on-non-cloud-distributions-3246d73d365081559a9ee8650409c5b4)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>

fix: prevent animated preview duplication on Vue↔Litegraph switch (#9938)

## Problem
SaveAnimatedPNG/WEBP nodes show duplicate output previews when switching
between Vue and Litegraph renderer modes.

## Root Cause
The `ANIM_PREVIEW_WIDGET` (`$$comfy_animation_preview`) DOM widget
lacked `canvasOnly: true`, so `shouldRenderAsVue()` in the widget
registry included it in Vue mode rendering. This caused both:
1. Vue's `ImagePreview.vue` (via `nodeMedia` computed from
`nodeOutputStore`)
2. The legacy `ANIM_PREVIEW_WIDGET` DOM widget (rendered as `WidgetDOM`)

to display simultaneously — duplicating the output preview.

## Fix
Add `canvasOnly: true` to the `ANIM_PREVIEW_WIDGET` options, matching
the pattern used by `IMAGE_PREVIEW` widget in
`useImagePreviewWidget.ts`. This ensures the legacy widget is filtered
out in Vue mode by `shouldRenderAsVue()`, leaving `ImagePreview.vue` as
the single source of truth.

## Testing
- All 539 vueNodes tests pass
- All 22 nodeOutputStore tests pass
- All 140 composables/node tests pass
- Typecheck passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9938-fix-prevent-animated-preview-duplication-on-Vue-Litegraph-switch-3246d73d365081019bbfd7e33a9c14fb)
by [Unito](https://www.unito.io)

1.43.0 (#10032)

Minor version increment to 1.43.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10032-1-43-0-3256d73d3650818e8408d25fdf28de48)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>

Feat/3d thumbnail inline rendering (#9471)

## Summary

The previous approach generated thumbnails server-side and uploaded them
as `model.glb.png` alongside the model file. This breaks on cloud
deployments where output files are renamed to content hashes, severing
the filename-based association between a model and its thumbnail.

Replace the server-upload approach with client-side Three.js rendering

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9471-Feat-3d-thumbnail-inline-rendering-31b6d73d3650816fbd7dd05b507aa80d)
by [Unito](https://www.unito.io)

test: add FeatureFlagHelper and QueueHelper for E2E test infrastructure (#9554)

## Summary

Add 2 reusable test helpers for Playwright E2E tests, integrated into
the ComfyPage fixture. These provide standardized patterns for mocking
feature flags and queue state across all E2E tests.

## Changes

- **`FeatureFlagHelper.ts`** — manage localStorage `ff:` prefixed
feature flags (`seedFlags` for init-time, `setFlags` for runtime) and
mock `/api/features` route
- **`QueueHelper.ts`** — mock `/api/queue` and `/api/history` routes
with configurable running/pending counts and success/error job entries
- **`ComfyPage.ts`** — integrate both helpers as
`comfyPage.featureFlags` and `comfyPage.queue`

## Review Focus

- Helper API design: are `seedFlags`/`setFlags`/`mockServerFeatures` the
right abstractions for feature flag testing?
- Queue mock fidelity: does the mock history shape match real ComfyUI
API responses closely enough?
- These are test-only infrastructure — no production code changes.

## Stack

This is the base PR for the Playwright E2E coverage stack. Waves 1-4 all
branch from this and can merge independently once this lands:
- **→ This PR**: Test infrastructure helpers
- #9555: Toasts, error overlay, selection toolbox, linear mode,
selection rectangle
- #9556: Node search, bottom panel, focus mode, job history, side panel
- #9557: Errors tab, node headers, queue notifications, settings sidebar
- #9558: Minimap, widget copy, floating menus, node library essentials

---------

Co-authored-by: GitHub Action <action@github.com>

feat: scaffold Astro 5 website app + design-system base.css

- Create apps/website/ with Astro 5, Vue 3, Tailwind v4 integration
- Static output, assetsPrefix /_website/, i18n (en + zh-CN)
- Nx targets: dev, serve, build, preview, typecheck
- Add base.css to design-system: brand tokens + Inter font-face only
- Add catalog entries: astro, @astrojs/vue, @astrojs/check, nanostores, @nanostores/vue

scaffold-01, scaffold-02

fix: add .gitignore and env.d.ts for Astro website app

feat: add layout shell — SEO head, analytics, nav, footer

- BaseLayout: OG/Twitter meta, canonical URL, GA4 GTM-NP9JM6K7,
  Vercel Analytics, ClientRouter for SPA navigation
- SiteNav: Comfy logo, Enterprise/Gallery/About/Careers links,
  Comfy Cloud + Comfy Hub CTA buttons, mobile hamburger menu
- SiteFooter: Product/Resources/Company/Legal columns,
  social icons (GitHub, Discord, X, Reddit, LinkedIn, Instagram)
- Add @vercel/analytics to workspace catalog and website deps

fix: address CodeRabbit review — ARIA wiring, absolute OG URLs, Analytics component

- SiteNav: add aria-controls, aria-expanded, and id for mobile menu
- BaseLayout: use absolute URLs for og:image and twitter:image
- BaseLayout: replace inline inject() with official <Analytics /> component

style: apply oxfmt formatting

fix: remove unused deps from website package.json (knip)

fix: clean up unused catalog entries from pnpm-workspace.yaml

feat: add Wave 3 homepage sections (hero, social proof, pillars, testimonials, CTAs, manifesto, academy, placeholders)
2026-03-17 07:17:40 -07:00
bymyself
df01219590 fix: address review feedback on scaffold PR
- Add private: true to package.json to prevent accidental npm publish
- Fix assetsPrefix to use Vercel URL (matching TDD) instead of /_website/ path
- Add Inter font files to public/fonts/ to fix 404s

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10142#pullrequestreview-2893455098
2026-03-17 07:17:14 -07:00
bymyself
04526cb519 fix: add site URL to astro config, include layouts in knip entry 2026-03-17 00:14:41 -07:00
bymyself
46a3f8fedf fix: hide template selector after shared workflow accept (#9913)
Hide the template selector when a first-time cloud user accepts a shared
workflow from a share link, so the shared workflow opens without the
onboarding template dialog lingering.

- **What**: Added shared-workflow loader behavior to close the global
template selector on accept actions (`copy-and-open` and `open-only`)
while keeping cancel behavior unchanged.
- **What**: Added targeted unit tests covering hide-on-accept and
no-hide-on-cancel behavior in the shared workflow URL loader.

Confirm that share-link accept paths now dismiss the template selector
and that cancel still leaves it available.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9913-fix-hide-template-selector-after-shared-workflow-accept-3236d73d365081099c04e350d499fad2)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>

fix: restore native copy/paste events for image paste support (#9914)

- Remove Ctrl+C and Ctrl+V keybindings from the keybinding service
defaults so native browser copy/paste events fire
- This restores image paste into LoadImage nodes, which broke after

PR #9459 moved Ctrl+C/V into the keybinding service, which calls
`event.preventDefault()` on keydown. This prevents the browser `paste`
event from firing, so `usePaste` (which detects images in the clipboard)
never runs. The `PasteFromClipboard` command only reads from
localStorage, completely bypassing image detection.

**Repro:** Copy a node → copy an image externally → try to paste the
image into a LoadImage node → gets old node data from localStorage
instead.

Remove Ctrl+C and Ctrl+V from `CORE_KEYBINDINGS` in `defaults.ts`. The
native browser events now fire as before, and `useCopy`/`usePaste`
handle them correctly. Ctrl+Shift+V, Ctrl+A, Delete, and Backspace
keybindings remain in the keybinding service.

Fixes #9459 (regression)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9914-fix-restore-native-copy-paste-events-for-image-paste-support-3236d73d365081c7ac53f983f316e10f)
by [Unito](https://www.unito.io)

fix: clear stale widget slotMetadata on link disconnect (#9885)

Fixes text field becoming non-editable when a previously linked input is
removed from a custom node.

When a widget's input was promoted to a slot, connected via a link, and
then the input was removed (e.g., by updating the custom node
definition), the widget retained stale `slotMetadata` with `linked:
true`. This prevented the widget from being editable.

In `refreshNodeSlots`, removed the `if (slotInfo)` guard so
`widget.slotMetadata` is always assigned — either to valid metadata or
`undefined`. This ensures stale linked state is cleared when inputs no
longer match widgets.

1. Text field remains editable after promote→connect→disconnect cycle
2. Text field returns to editable state when noodle disconnected
3. No mode switching needed to restore editability

- Added regression test: "clears stale slotMetadata when input no longer
matches widget"
- All existing tests pass (18/18 in affected file)

---
**Note: This PR currently contains only the RED (failing test) commit
for TDD verification. The GREEN (fix) commit will be pushed after CI
confirms the test failure.**

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9885-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3226d73d365081269319c027b42d9f6b)
by [Unito](https://www.unito.io)

fix: stabilize subgraph promoted widget identity and rendering (#9896)

Fix subgraph promoted widget identity/rendering so on-node widgets stay
correct through configure/hydration churn, duplicate names, and
linked+independent coexistence.

- **Subgraph promotion reconciliation**: stabilize linked-entry identity
by subgraph slot id, preserve deterministic linked representative
selection, and prune stale alias/fallback entries without dropping
legitimate independent promotions.
- **Promoted view resolution**: bind slot mapping by promoted view
object identity (`getSlotFromWidget` / `getWidgetFromSlot`) to avoid
same-name collisions.
- **On-node widget rendering**: harden `NodeWidgets` identity and dedup
to avoid visual aliasing, prefer visible duplicates over hidden stale
entries, include type/source execution identity, and avoid collapsing
transient unresolved entries.
- **Mapping correctness**: update `useGraphNodeManager` promoted source
mapping to resolve by input target only when the promoted view is
actually bound to that input.
- **Subgraph input uniqueness**: ensure empty-slot promotion creates
unique input names (`seed`, `seed_1`, etc.) for same-name multi-source
promotions.
- **Safety fix**: guard against undefined canvas in slot-link
interaction.
- **Tests/fixtures**: add focused regressions for fixture path
`subgraph_complex_promotion_1`, linked+independent same-name cases,
duplicate-name identity mapping, dedup behavior, and input-name
uniqueness.

Validate behavior around transient configure/hydration states (`-1` id
to concrete id), duplicate-name promotions, linked representative
recovery, and that dedup never hides legitimate widgets while still
removing true duplicates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9896-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3226d73d365081c8a1e8d0a5a22e826d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>

1.42.5 (#9906)

Patch version increment to 1.42.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>

fix: skip redundant appScalePercentage updates during zoom/pan (#9403)

Add equality check before updating `appScalePercentage` reactive ref.

Firefox profiler shows 586 `setElementText` markers from continuous text
interpolation updates during zoom/pan. The rounded percentage value
often doesn't change between events.

Extract `updateAppScalePercentage()` helper with equality guard —
compares new rounded value to current before assigning to the ref.

Expected: eliminates ~90% of `setElementText` markers during zoom/pan

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9403-fix-skip-redundant-appScalePercentage-updates-during-zoom-pan-31a6d73d3650812db8f2d68ac73c95b0)
by [Unito](https://www.unito.io)

test: add browser test for textarea right-click context menu in subgraph (#9891)

Add E2E test coverage for the textarea widget right-click context menu
inside subgraphs.

The fix was shipped in #9840 — this PR adds the missing browser test.

- Loads a subgraph workflow with a CLIPTextEncode (textarea) node
- Navigates into the subgraph
- Right-clicks the textarea DOM element
- Asserts that the ComfyUI "Promote Widget" context menu option appears

- Fixes the test gap from #9840
- Notion ticket: d7a53160-e1e1-42bb-a5ac-c0c2702c629c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9891-test-add-browser-test-for-textarea-right-click-context-menu-in-subgraph-3226d73d365081a4be51f89b5d505361)
by [Unito](https://www.unito.io)

feat: expand CDP perf metrics — add DOM nodes, script duration, event listeners (#9887)

Expands the performance testing infrastructure to collect 4 additional
CDP metrics that are already returned by `Performance.getMetrics` but
were not being read. This is a zero-cost expansion — no additional CDP
calls, just reading more fields from the existing response.

| Metric | CDP Source | What It Detects |
|---|---|---|
| `domNodes` | `Nodes` | DOM node count delta — widget DOM leaks during
node create/destroy |
| `jsHeapTotalBytes` | `JSHeapTotalSize` | Total heap delta — combined
with `heapDeltaBytes` shows GC pressure |
| `scriptDurationMs` | `ScriptDuration` | JS execution time vs total
task time — script vs rendering balance |
| `eventListeners` | `JSEventListeners` | Listener count delta — detects
listener accumulation across lifecycle |

- Added 4 fields to `PerfSnapshot` interface
- Added 4 fields to `PerfMeasurement` interface
- Wired through `getSnapshot()` and `stopMeasuring()`

- Added 4 fields to `PerfMeasurement` interface
- Expanded `MetricKey` type and `REPORTED_METRICS` array with 3 new
reported metrics (`domNodes`, `scriptDurationMs`, `eventListeners`)
- `jsHeapTotalBytes` is collected but not in `REPORTED_METRICS` — it's
used alongside `heapDeltaBytes` for GC pressure ratio analysis

From a gap analysis of all ~30 CDP metrics, these were identified as
highest priority for ComfyUI:
- **`Nodes`** (P0): ComfyUI dynamically creates/destroys widget DOM. DOM
bloat from leaked widgets is a key performance risk, especially for Vue
Nodes 2.0.
- **`ScriptDuration`** (P1): Separates JS execution from layout/paint.
Reveals whether perf issues are script-heavy or rendering-heavy.
- **`JSEventListeners`** (P1): Widget lifecycle can leak listeners
across node add/remove cycles.
- **`JSHeapTotalSize`** (P1): With `JSHeapUsedSize`, the ratio shows GC
fragmentation pressure.

The `PerfMeasurement` interface is extended (not changed). Old baseline
`perf-metrics.json` files without these fields will have `undefined`
values, which the report script handles gracefully (shows `—` for
missing data).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9887-feat-expand-CDP-perf-metrics-add-DOM-nodes-script-duration-event-listeners-3226d73d3650818abea1d4a441667c38)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>

fix: prevent white flash when opening mask editor (#9860)

- Remove hardcoded `bg-white` from mask editor canvas background div to
prevent white flash on dialog open
- Add a loading spinner while the mask editor initializes (image
loading, canvas setup, GPU resources)
- Background color is set dynamically by `setCanvasBackground()` after
initialization

Fixes #9852

https://github.com/user-attachments/assets/7da61e32-671b-4056-b5ec-8cb246fc7689

https://github.com/user-attachments/assets/bfdedc69-f690-42c5-8591-619623c04f55

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9860-fix-prevent-white-flash-when-opening-mask-editor-3226d73d365081de9b7ad4622438e6ed)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

fix: prevent live preview dimension flicker between frames (#9937)

Fix "Calculating dimensions" text flickering during live sampling
preview in Vue renderer.

- **What**: Stop resetting `actualDimensions` to `null` on every
`imageUrl` change. Previous dimensions are retained while the new frame
loads, eliminating the flicker. Error state is still reset correctly.

The watcher on `props.imageUrl` previously reset both `actualDimensions`
and `imageError`. Now it only resets `imageError`, since
`handleImageLoad` updates dimensions when the new frame actually loads.
This means stale dimensions show briefly between frames, which is
intentionally better than showing "Calculating dimensions" text.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9937-fix-prevent-live-preview-dimension-flicker-between-frames-3246d73d36508154a676e5996112354f)
by [Unito](https://www.unito.io)

feat: make Vue nodes (Nodes 2.0) default for new desktop installs (#9947)

Makes Vue nodes (Nodes 2.0) the default renderer for new desktop app
installs (version ≥1.41.0), matching the behavior already live for cloud
new installs.

Step 2 of the Nodes 2.0 rollout sequence:
1.  Cloud new installs (≥1.41.0) — DONE
2. 👉 **Desktop app (new installs)** — this PR
3.  Local installs
4.  Remove Beta tag
5.  GTM announcement

No forced migration — only changes the default for new installs.
Existing users keep their setting. Rollback is a settings flip.

In `coreSettings.ts`, the `defaultsByInstallVersion` for
`Comfy.VueNodes.Enabled` changes from:
```typescript
defaultsByInstallVersion: { '1.41.0': isCloud },
```
to:
```typescript
defaultsByInstallVersion: { '1.41.0': isCloud || isDesktop },
```

- M2 perf target (≥52 FPS on 245-node workflow) — layer merge landed,
likely met
- M-DevRel migration docs (blocks Beta tag removal, not this flip)

Draft PR — ceremonial, to be merged when M2 checkpoint passes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9947-feat-make-Vue-nodes-Nodes-2-0-default-for-new-desktop-installs-3246d73d365081b280dfff932c7aa016)
by [Unito](https://www.unito.io)

fix: fix perf CI pipeline — z-score baselines, force-push staleness, baseline storage (#9886)

Fixes three critical issues with the CI performance reporting pipeline
that made perf reports useless on PRs (demonstrated by PR #9248 — deep
watcher removal merged without useful perf signal).

**Root cause:** PR #9305 added z-score statistical analysis code to
`perf-report.ts`, but the historical data download step was placed in
the wrong workflow file. The report is generated in
`pr-perf-report.yaml` (a `workflow_run`-triggered job), but the
historical download was in `ci-perf-report.yaml` (the test runner) —
different runners, different filesystems.

**Fix:** Implement `perf-data` orphan branch storage:
- On push to main: save `perf-metrics.json` to `perf-data` branch with
timestamped filename
- On PR report: fetch last 5 baselines from `perf-data` branch into
`temp/perf-history/`
- Rolling window of 20 baselines, oldest pruned automatically
- Same pattern used by `github-action-benchmark` (33.7k repos)

**Root cause:** `cancel-in-progress: true` kills the perf test run
before it uploads artifacts. The downstream report workflow only
triggers on `conclusion == 'success'` — cancelled runs are ignored, so
the comment from the first successful run goes stale.

**Fix:**
- Change `cancel-in-progress: false` — with GitHub's queue depth of 1,
rapid pushes (A,B,C,D) run A and D, skipping B and C
- Add SHA validation in `pr-perf-report.yaml` — before posting, check if
the workflow_run's head SHA still matches the PR's current head. Skip
posting stale results.

- `contents: write` on CI job (needed for pushing to perf-data branch)
- `actions: read` on both workflows (needed for artifact/baseline
access)

After merging, create the `perf-data` orphan branch:
```bash
git checkout --orphan perf-data
git rm -rf .
echo '# Performance Baselines' > README.md
mkdir -p baselines
git add README.md baselines
git commit -m 'Initialize perf-data branch'
git push origin perf-data
```

The first 2 pushes to main after setup will build up variance data, and
z-scores will start appearing in PR reports (threshold is
`historical.length >= 2`).

- YAML validated with `yaml.safe_load()`
- `perf-report.ts` `loadHistoricalReports()` already reads from
`temp/perf-history/<index>/perf-metrics.json` — no code changes needed
- All new steps use `continue-on-error: true` for graceful degradation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9886-fix-fix-perf-CI-pipeline-z-score-baselines-force-push-staleness-baseline-storage-3226d73d365081538424c7945e71f308)
by [Unito](https://www.unito.io)

draft: add red-green-fix skill for verified bug fix workflow (#9954)

- Add a Claude Code skill (`/red-green-fix`) that enforces the red-green
commit pattern for bug fixes
- Ensures a failing test is committed first (red CI), then the fix is
committed separately (green CI)
- Gives reviewers proof that the test actually catches the bug
- Includes `reference/testing-anti-patterns.md` with common mistakes
contextualized to this codebase

```
.claude/skills/red-green-fix/
├── SKILL.md                              # Main skill definition
└── reference/
    └── testing-anti-patterns.md          # Anti-patterns guide
```

- [ ] Invoke `/red-green-fix <bug description>` in Claude Code and
verify the two-step workflow
- [ ] Confirm PR template includes red-green verification table
- [ ] Review anti-patterns reference for completeness

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9954-draft-add-red-green-fix-skill-for-verified-bug-fix-workflow-3246d73d365081339a83dc09263b0f33)
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>

test: add large-graph perf test with 245-node workflow (backlog N5) (#9940)

Adds a 245-node workflow asset and two `@perf` tests to establish a
baseline for large-graph performance regressions (Tier 6 in the
performance backlog).

Backlog item N5: we need CI regression detection for compositor layer
management, GPU texture count, and transform pane cost at 245+ nodes.
This is PR1 of 2 — establishes baseline metrics on main. Future
optimization PRs will show improvement deltas against this baseline.

- **`large graph idle rendering`** — 120 frames idle with 245 nodes,
measures style recalcs, layouts, task duration, heap delta
- **`large graph pan interaction`** — middle-click pan across 245 nodes,
stresses compositor layer management and transform recalculation

`browser_tests/assets/large-graph-workflow.json` — 245 nodes (49
pipelines of CheckpointLoader → 2× CLIPTextEncode → KSampler +
EmptyLatentImage), 294 links. Minimal structure focused on node count.

- [x] `pnpm typecheck:browser` passes
- [x] `pnpm lint` passes (eslint on changed file)
- [x] All link references in JSON validated programmatically

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9940-test-add-large-graph-perf-test-with-245-node-workflow-backlog-N5-3246d73d365081f6b5d8ddb9a85e6ad0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>

feat: add Ingest API codegen with Zod schema generation (#9932)

- Add `packages/ingest-types/` package that auto-generates TypeScript
types and Zod schemas from the Ingest service OpenAPI spec
- Uses `@hey-api/openapi-ts` with built-in Zod plugin (Zod v3
compatible)
- Filters out overlapping endpoints shared with the local ComfyUI Python
backend
- Generates **493 TypeScript types** and **256 Zod schemas** covering
cloud-only endpoints
- Configure knip to ignore generated files

The cloud repo pushes generated types to this repo (push model, no
private repo cloning).
See: Comfy-Org/cloud#2858

Codegen targets are controlled by the **exclude list** in
`packages/ingest-types/openapi-ts.config.ts`. Everything in the Ingest
`openapi.yaml` is included **except** overlapping endpoints that also
exist in the local ComfyUI Python backend:

**Excluded (overlapping with ComfyUI Python):**
`/prompt`, `/queue`, `/history`, `/object_info`, `/features`,
`/settings`, `/system_stats`, `/interrupt`, `/upload/*`, `/view`,
`/jobs`, `/userdata`, `/webhooks/*`, `/internal/*`

**Included (cloud-only, codegen targets):**
`/workspaces/*`, `/billing/*`, `/secrets/*`, `/assets/*`, `/tasks/*`,
`/auth/*`, `/workflows/*`, `/workspace/*`, `/user`, `/settings/{key}`,
`/tags`, `/feedback`, `/invite_code/*`, `/experiment/models/*`,
`/global_subgraphs/*`

This PR only sets up the codegen infrastructure. A follow-up PR should
replace manually maintained types with imports from
`@comfyorg/ingest-types`:

| File | Lines | Current | Replace with |
|------|-------|---------|-------------|
| `src/platform/workspace/api/workspaceApi.ts` | ~270 | TS interfaces |
`import type { ... } from '@comfyorg/ingest-types'` |
| `src/platform/secrets/types.ts` | ~32 | TS interfaces | `import type {
... } from '@comfyorg/ingest-types'` |
| `src/platform/assets/schemas/assetSchema.ts` | ~125 | Zod schemas |
`import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/assets/schemas/mediaAssetSchema.ts` | ~50 | Zod schemas
| `import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/tasks/services/taskService.ts` | ~70 | Zod schemas |
`import { ... } from '@comfyorg/ingest-types/zod'` |
| `src/platform/workspace/workspaceTypes.ts` | ~6 | TS interface |
`export type { ... } from '@comfyorg/ingest-types'` |

- [x] `pnpm generate` in `packages/ingest-types/` produces
`types.gen.ts` and `zod.gen.ts`
- [x] `pnpm typecheck` passes
- [x] Pre-commit hooks pass (lint, typecheck, format)
- [x] Generated Zod schemas validate correct data and reject invalid
data
- [x] No import conflicts with existing code (generated types are
isolated in separate package)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>

feat: surface missing models in Error Tab for OSS and remove legacy dialog (#9921)

- Surface missing models in the Error Tab for OSS environments,
replacing the legacy modal dialog
- Add Download button per model and Download All button in group header
with file size display
- Move download business logic from `components/dialog/content` to
`platform/missingModel`
- Remove legacy missing models dialog components and composable

- **Pipeline**: Remove `isCloud` guard from `scanAllModelCandidates` and
`surfaceMissingModels` so OSS detects missing models
- **Grouping**: Group non-asset-supported models by directory in OSS
instead of lumping into UNSUPPORTED
- **UI**: Add Download button (matching Install Node Pack design) and
Download All header button
- **Store**: Add `folderPaths`/`fileSizes` state with setter methods,
race condition guard
- **Cleanup**: Delete `MissingModelsContent`, `MissingModelsHeader`,
`MissingModelsFooter`, `useMissingModelsDialog`, `missingModelsUtils`
- **Tests**: Add OSS/Cloud grouping tests, migrate Playwright E2E to
Error Tab, improve test isolation
- **Snapshots**: Reset Playwright screenshot expectations since OSS
missing model error detection now causes red highlights on affected
nodes
- **Accessibility**: Add `aria-label` with model name, `aria-expanded`
on toggle, warning icon for unknown category

- [x] Unit tests pass (86 tests)
- [x] TypeScript typecheck passes
- [x] knip passes
- [x] Load workflow with missing models in OSS → Error Tab shows missing
models grouped by directory
- [x] Download button triggers browser download with correct URL
- [x] Download All button downloads all downloadable models
- [x] Cloud environment behavior unchanged
- [x] Playwright E2E: `pnpm test:browser:local -- --grep "Missing models
in Error Tab"`

https://github.com/user-attachments/assets/12f15e09-215a-4c58-87ed-39bbffd1359c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9921-feat-surface-missing-models-in-Error-Tab-for-OSS-and-remove-legacy-dialog-3236d73d365081f0a9dfc291978f5ecf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>

fix: cloud subscribe redirect hangs waiting for billing init (#9965)

Fix /cloud/subscribe route hanging indefinitely because billing context
never initializes during the onboarding flow.

- **What**: Replace passive `await until(isInitialized).toBe(true)` with
explicit `await initialize()` in CloudSubscriptionRedirectView. Remove
unused `until` import.

![Kapture 2026-03-15 at 23 16
22](https://github.com/user-attachments/assets/0a12487b-b39a-4f96-9a4c-96a01facfdd8)

In the onboarding flow, `useTeamWorkspaceStore().activeWorkspace` is not
set, so `useBillingContext`'s internal watch (which triggers
`initialize()` on workspace change) enters the `!newWorkspaceId` branch
— it resets `isInitialized` to `false` and returns without ever calling
`initialize()`. The old code then awaited `isInitialized` becoming
`true` forever.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9965-fix-cloud-subscribe-redirect-hangs-waiting-for-billing-init-3246d73d3650812d93ebd477c544fa0a)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>

feat: add TBT/frameDuration metrics and new perf test scenarios (#9910)

Adds Total Blocking Time (TBT) and frame duration metrics to the
performance testing infrastructure, plus three new test scenarios
covering zoom, pan, and many-nodes-idle.

- **`totalBlockingTimeMs`** — Computed from PerformanceObserver
`longtask` entries: `sum(duration - 50ms)` for tasks >50ms. Measures
main thread blocking.
- **`frameDurationMs`** — Average frame duration via rAF timing (16.67ms
= 60fps target). Measures rendering smoothness.

| Scenario | Description |
|---|---|
| `canvas-zoom-sweep` | 10 zoom-in + 10 zoom-out cycles on default
workflow |
| `canvas-pan-many-nodes` | 10 pan sweeps over 100-node workflow |
| `canvas-many-nodes-idle` | 2-second idle measurement with 100 nodes
rendered |

- `PerformanceHelper.ts`: Installs PerformanceObserver for longtask,
collects TBT, measures frame duration via rAF
- `perf-report.ts`: Reports TBT and frame duration in PR comment tables
- `browser_tests/assets/perf/many_nodes_100.json`: 100-node (10×10 grid)
test fixture

- TBT collection clears entries at `startMeasuring()` and reads at
`stopMeasuring()` — ensure no race with observer buffering
- Frame duration sampling uses 10 frames — enough for signal without
slowing tests

Depends on: #9886, #9887

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9910-feat-add-TBT-frameDuration-metrics-and-new-perf-test-scenarios-3236d73d365081488ae3c594a8bf7cff)
by [Unito](https://www.unito.io)

fix: LGraphGroup paste position (#9962)

Fix group paste position: groups now paste at the cursor location
instead of on top of the original.

- **What**: Added LGraphGroup offset handling in _deserializeItems()
position adjustment loop, matching existing LGraphNode and Reroute
behavior.

Before:

https://github.com/user-attachments/assets/e317af10-8009-4092-9d14-de79316cd853

After:

https://github.com/user-attachments/assets/f4ffefd5-519a-4592-812c-c88e3b5940fd

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9962-fix-LGraphGroup-paste-position-3246d73d365081eea5b2e2507da861de)
by [Unito](https://www.unito.io)

fix: tree explorer nodes not filling parent container width (#9964)

Fix tree explorer nodes not filling the full width of the sidebar
container, causing text to overflow instead of truncating.

- **What**: Add `min-w-0` to `TreeRoot` to allow flex shrinking within
sidebar. Add `w-full` and `min-w-0` to tree node rows so
absolutely-positioned virtualizer items fill the container width and
text truncates correctly.
<img width="365" height="749" alt="image"
src="https://github.com/user-attachments/assets/320910f3-52ad-4634-a935-6bd1a40aea7f"
/>

The virtualizer renders each item with `position: absolute; left: 0` but
no explicit width, so rows would size to content rather than filling the
container. Adding `w-full` ensures rows stretch to 100% of the
virtualizer container, and `min-w-0` allows proper flex shrinking for
deep indentation levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9964-fix-tree-explorer-nodes-not-filling-parent-container-width-3246d73d36508138be38fdcac15ae4ef)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>

feat: add Copy URL button to missing model rows for OSS (#9966)

1.42.6 (#9986)

Patch version increment to 1.42.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9986-1-42-6-3256d73d365081a28bfad82022ce3440)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>

fix: block missing e2e regression coverage in CodeRabbit (#9987)

Make the CodeRabbit end-to-end regression coverage check actually block
fix-like PRs until it is resolved or explicitly overridden by a
requested reviewer, and harden the prompt so it evaluates only PR-local
metadata.

- **What**: Set the `End-to-end regression coverage for fixes` custom
check mode from `warning` to `error`
- **What**: Enable `reviews.request_changes_workflow` so CodeRabbit can
block on failed `error` pre-merge checks
- **What**: Set
`reviews.pre_merge_checks.override_requested_reviewers_only` to `true`
so only requested reviewers can bypass a failed check
- **What**: Tighten the custom check instructions to use only PR
metadata in review context, avoid shell commands, and avoid reverse-diff
or base-branch file evaluation

Confirm this is the intended CodeRabbit enforcement model for missing
Playwright regression coverage on fix-like PRs and that the prompt
wording is strict enough to avoid false positives from reversed diffs.

fix: add reve and elevenlabs to icon safelist (#9990)

Reve and ElevenLabs provider icons were not displaying in the node
library because they were missing from the Tailwind icon safelist.

- **What**: Add `reve` and `elevenlabs` to the `@source inline` safelist
in `style.css` so `icon-[comfy--reve]` and `icon-[comfy--elevenlabs]`
classes are generated. Add corresponding `PROVIDER_COLORS` entries in
`categoryUtil.ts`.

<img width="308" height="106" alt="image"
src="https://github.com/user-attachments/assets/d488898a-fbad-4af0-8921-0e8ee7d4705a"
/>
<img width="308" height="78" alt="image"
src="https://github.com/user-attachments/assets/2b3b7172-095b-415e-a49a-d303977e0abc"
/>

The SVG files already existed in `packages/design-system/src/icons/` but
Tailwind's tree-shaking dropped the classes since they're only used
dynamically via `getProviderIcon()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9990-fix-add-reve-and-elevenlabs-to-icon-safelist-3256d73d36508105994fcdd5d0568027)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>

fix: mask editor save shows blank image in Load Image node (#9984)

Mask editor save was showing a blank image in the Load Image node
(legacy nodes mode, not Nodes 2.0) because
`updateNodeWithServerReferences` called `updateNodeImages`, which
silently no-ops when the node has no pre-existing execution outputs.
Replaced with `setNodeOutputs` which properly creates output entries
regardless of prior state.

**Affects:** Legacy nodes mode only. Nodes 2.0 (Vue Nodes) renders
images via Vue components and is not affected.

- Fixes #9983
- Fixes #9782
- Fixes #9952

| Commit | SHA | CI Status | Run | Purpose |
|--------|-----|-----------|-----|---------|
| `test: add failing test for mask editor save showing blank image` |
`0ab66e8` | 🔴
[Red](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23125427860)
| CI: Tests Unit **failure** | Proves the test catches the bug |
| `fix: mask editor save shows blank image in Load Image node` |
`564cc9c` | 🟢
[Green](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23127289891)
| CI: Tests Unit **success** | Proves the fix resolves the bug |

https://github.com/user-attachments/assets/8d5c36ce-2c5e-4609-b246-dcf896c4a8e7

https://github.com/user-attachments/assets/c8ae4f0e-3da0-40f2-a543-d1d5a6bce795

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [ ] E2E regression test not added: mask editor save requires canvas
pixel manipulation + server upload round-trip which is covered by the
existing unit test mocking the full `save()` flow. The Playwright test
infrastructure does not currently support mask editor interactions (draw
+ save).
- [x] Manual verification (legacy nodes mode): Load Image → upload →
mask editor → draw → save → verify image refreshes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

fix: allow URL input for free tier users, gate on import button (#10024)

- Remove free-tier restriction from the URL input field in
`MissingModelUrlInput.vue` so it is always editable
- Move the subscription check (`canImportModels`) to the Import button
click handler — free-tier users see the upgrade modal only when they
attempt to import
- Extract inline ternary to named `handleImportClick` method for clarity

- [x] Unit tests added (`MissingModelUrlInput.test.ts`) verifying:
  - URL input is always editable regardless of subscription tier
  - Import button calls `handleImport` for paid users
- Import button calls `showUploadDialog` (upgrade modal) for free-tier
users
- [x] Verify URL input is editable for free-tier users on cloud
- [x] Verify clicking Import as free-tier opens the subscription modal
- [x] Verify paid users can import normally without changes

Playwright E2E regression tests are impractical for this change because
`MissingModelUrlInput` only renders when `isAssetSupported` is true,
which requires `isCloud` — a compile-time constant (`__DISTRIBUTION__`).
The OSS test build always sets `isCloud = false`, so the component never
renders in the E2E environment. Unit tests with mocked feature flags
provide equivalent behavioral coverage.

fix: prevent subscription UI from rendering on non-cloud distributions (#9958)

Prevent Plans & Pricing dialog, subscription buttons, and cloud-only
menu items from appearing on desktop/localhost distributions.

- **What**: Add `isCloud` guards to
`useSubscriptionDialog.showPricingTable`, `TopbarSubscribeButton`, and
`CurrentUserPopoverLegacy` so subscription UI only renders on cloud
- **Tests**: 24 tests across 3 test files (1 modified, 2 new) covering
cloud/non-cloud behavior

- Guard placement in `CurrentUserPopoverLegacy.vue` — multiple `v-if`
conditions updated to include `isCloud`
- Early-return in `showPricingTable` as a defense-in-depth measure

Fixes COM-16820

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9958-fix-prevent-subscription-UI-from-rendering-on-non-cloud-distributions-3246d73d365081559a9ee8650409c5b4)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>

fix: prevent animated preview duplication on Vue↔Litegraph switch (#9938)

SaveAnimatedPNG/WEBP nodes show duplicate output previews when switching
between Vue and Litegraph renderer modes.

The `ANIM_PREVIEW_WIDGET` (`$$comfy_animation_preview`) DOM widget
lacked `canvasOnly: true`, so `shouldRenderAsVue()` in the widget
registry included it in Vue mode rendering. This caused both:
1. Vue's `ImagePreview.vue` (via `nodeMedia` computed from
`nodeOutputStore`)
2. The legacy `ANIM_PREVIEW_WIDGET` DOM widget (rendered as `WidgetDOM`)

to display simultaneously — duplicating the output preview.

Add `canvasOnly: true` to the `ANIM_PREVIEW_WIDGET` options, matching
the pattern used by `IMAGE_PREVIEW` widget in
`useImagePreviewWidget.ts`. This ensures the legacy widget is filtered
out in Vue mode by `shouldRenderAsVue()`, leaving `ImagePreview.vue` as
the single source of truth.

- All 539 vueNodes tests pass
- All 22 nodeOutputStore tests pass
- All 140 composables/node tests pass
- Typecheck passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9938-fix-prevent-animated-preview-duplication-on-Vue-Litegraph-switch-3246d73d365081019bbfd7e33a9c14fb)
by [Unito](https://www.unito.io)

1.43.0 (#10032)

Minor version increment to 1.43.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10032-1-43-0-3256d73d3650818e8408d25fdf28de48)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>

Feat/3d thumbnail inline rendering (#9471)

The previous approach generated thumbnails server-side and uploaded them
as `model.glb.png` alongside the model file. This breaks on cloud
deployments where output files are renamed to content hashes, severing
the filename-based association between a model and its thumbnail.

Replace the server-upload approach with client-side Three.js rendering

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9471-Feat-3d-thumbnail-inline-rendering-31b6d73d3650816fbd7dd05b507aa80d)
by [Unito](https://www.unito.io)

test: add FeatureFlagHelper and QueueHelper for E2E test infrastructure (#9554)

Add 2 reusable test helpers for Playwright E2E tests, integrated into
the ComfyPage fixture. These provide standardized patterns for mocking
feature flags and queue state across all E2E tests.

- **`FeatureFlagHelper.ts`** — manage localStorage `ff:` prefixed
feature flags (`seedFlags` for init-time, `setFlags` for runtime) and
mock `/api/features` route
- **`QueueHelper.ts`** — mock `/api/queue` and `/api/history` routes
with configurable running/pending counts and success/error job entries
- **`ComfyPage.ts`** — integrate both helpers as
`comfyPage.featureFlags` and `comfyPage.queue`

- Helper API design: are `seedFlags`/`setFlags`/`mockServerFeatures` the
right abstractions for feature flag testing?
- Queue mock fidelity: does the mock history shape match real ComfyUI
API responses closely enough?
- These are test-only infrastructure — no production code changes.

This is the base PR for the Playwright E2E coverage stack. Waves 1-4 all
branch from this and can merge independently once this lands:
- **→ This PR**: Test infrastructure helpers
- #9555: Toasts, error overlay, selection toolbox, linear mode,
selection rectangle
- #9556: Node search, bottom panel, focus mode, job history, side panel
- #9557: Errors tab, node headers, queue notifications, settings sidebar
- #9558: Minimap, widget copy, floating menus, node library essentials

---------

Co-authored-by: GitHub Action <action@github.com>

feat: scaffold Astro 5 website app + design-system base.css

- Create apps/website/ with Astro 5, Vue 3, Tailwind v4 integration
- Static output, assetsPrefix /_website/, i18n (en + zh-CN)
- Nx targets: dev, serve, build, preview, typecheck
- Add base.css to design-system: brand tokens + Inter font-face only
- Add catalog entries: astro, @astrojs/vue, @astrojs/check, nanostores, @nanostores/vue

scaffold-01, scaffold-02

fix: add .gitignore and env.d.ts for Astro website app

feat: add layout shell — SEO head, analytics, nav, footer

- BaseLayout: OG/Twitter meta, canonical URL, GA4 GTM-NP9JM6K7,
  Vercel Analytics, ClientRouter for SPA navigation
- SiteNav: Comfy logo, Enterprise/Gallery/About/Careers links,
  Comfy Cloud + Comfy Hub CTA buttons, mobile hamburger menu
- SiteFooter: Product/Resources/Company/Legal columns,
  social icons (GitHub, Discord, X, Reddit, LinkedIn, Instagram)
- Add @vercel/analytics to workspace catalog and website deps

fix: address CodeRabbit review — ARIA wiring, absolute OG URLs, Analytics component

- SiteNav: add aria-controls, aria-expanded, and id for mobile menu
- BaseLayout: use absolute URLs for og:image and twitter:image
- BaseLayout: replace inline inject() with official <Analytics /> component

style: apply oxfmt formatting

fix: remove unused deps from website package.json (knip)

fix: clean up unused catalog entries from pnpm-workspace.yaml

feat: add Wave 3 homepage sections (hero, social proof, pillars, testimonials, CTAs, manifesto, academy, placeholders)
2026-03-16 23:47:05 -07:00
22 changed files with 2684 additions and 16 deletions

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.

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
const columns = [
{
title: 'Product',
links: [
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
]
},
{
title: 'Resources',
links: [
{ label: 'Documentation', href: 'https://docs.comfy.org' },
{ label: 'Blog', href: 'https://blog.comfy.org' },
{ label: 'Gallery', href: '/gallery' },
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
]
},
{
title: 'Company',
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
]
},
{
title: 'Legal',
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
]
}
]
const socials = [
{
label: 'GitHub',
href: 'https://github.com/comfyanonymous/ComfyUI',
icon: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z'
},
{
label: 'Discord',
href: 'https://discord.gg/comfyorg',
icon: 'M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026c.462-.62.874-1.275 1.226-1.963.021-.04.001-.088-.041-.104a13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028ZM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38Zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38Z'
},
{
label: 'X',
href: 'https://x.com/comaboratory',
icon: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z'
},
{
label: 'Reddit',
href: 'https://reddit.com/r/comfyui',
icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5.8 11.33c.02.16.03.33.03.5 0 2.55-2.97 4.63-6.63 4.63-3.65 0-6.62-2.07-6.62-4.63 0-.17.01-.34.03-.5a1.58 1.58 0 0 1-.63-1.27c0-.88.72-1.59 1.6-1.59.44 0 .83.18 1.12.46 1.1-.79 2.62-1.3 4.31-1.37l.73-3.44a.32.32 0 0 1 .39-.24l2.43.52a1.13 1.13 0 0 1 2.15.36 1.13 1.13 0 0 1-1.13 1.12 1.13 1.13 0 0 1-1.08-.82l-2.16-.46-.65 3.07c1.65.09 3.14.59 4.22 1.36.29-.28.69-.46 1.13-.46.88 0 1.6.71 1.6 1.59 0 .52-.25.97-.63 1.27ZM9.5 13.5c0 .63.51 1.13 1.13 1.13s1.12-.5 1.12-1.13-.5-1.12-1.12-1.12-1.13.5-1.13 1.12Zm5.75 2.55c-.69.69-2 .73-3.25.73s-2.56-.04-3.25-.73a.32.32 0 1 1 .45-.45c.44.44 1.37.6 2.8.6 1.43 0 2.37-.16 2.8-.6a.32.32 0 1 1 .45.45Zm-.37-1.42c.62 0 1.13-.5 1.13-1.13 0-.62-.51-1.12-1.13-1.12-.63 0-1.13.5-1.13 1.12 0 .63.5 1.13 1.13 1.13Z'
},
{
label: 'LinkedIn',
href: 'https://linkedin.com/company/comfyorg',
icon: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286ZM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065Zm1.782 13.019H3.555V9h3.564v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z'
},
{
label: 'Instagram',
href: 'https://instagram.com/comfyorg',
icon: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z'
}
]
</script>
<template>
<footer class="border-t border-white/10 bg-black">
<div
class="mx-auto grid max-w-7xl gap-8 px-6 py-16 sm:grid-cols-2 lg:grid-cols-5"
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
Comfy
</a>
<p class="mt-4 text-sm text-smoke-700">
Professional control of visual AI.
</p>
</div>
<!-- Link columns -->
<nav
v-for="column in columns"
:key="column.title"
:aria-label="column.title"
class="flex flex-col gap-3"
>
<h3 class="text-sm font-semibold text-white">{{ column.title }}</h3>
<a
v-for="link in column.links"
:key="link.href"
:href="link.href"
class="text-sm text-smoke-700 transition-colors hover:text-white"
>
{{ link.label }}
</a>
</nav>
</div>
<!-- Bottom bar -->
<div class="border-t border-white/10">
<div
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 py-6 sm:flex-row"
>
<p class="text-sm text-smoke-700">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
</p>
<!-- Social icons -->
<div class="flex items-center gap-4">
<a
v-for="social in socials"
:key="social.label"
:href="social.href"
:aria-label="social.label"
target="_blank"
rel="noopener noreferrer"
class="text-smoke-700 transition-colors hover:text-white"
>
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path :d="social.icon" />
</svg>
</a>
</div>
</div>
</div>
</footer>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const mobileMenuOpen = ref(false)
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && mobileMenuOpen.value) {
mobileMenuOpen.value = false
}
}
function onAfterSwap() {
mobileMenuOpen.value = false
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
document.addEventListener('astro:after-swap', onAfterSwap)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('astro:after-swap', onAfterSwap)
})
</script>
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
Comfy
</a>
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
>
{{ link.label }}
</a>
<!-- CTA buttons -->
<div class="flex items-center gap-3">
<a
href="https://app.comfy.org"
class="rounded-full bg-brand-yellow px-5 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
COMFY CLOUD
</a>
<a
href="https://hub.comfy.org"
class="rounded-full border border-brand-yellow px-5 py-2 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
>
COMFY HUB
</a>
</div>
</div>
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && 'translate-y-2 rotate-45'"
/>
<span
class="block h-0.5 w-6 bg-white transition-opacity"
:class="mobileMenuOpen && 'opacity-0'"
/>
<span
class="block h-0.5 w-6 bg-white transition-transform"
:class="mobileMenuOpen && '-translate-y-2 -rotate-45'"
/>
</button>
</div>
<!-- Mobile menu -->
<div
v-show="mobileMenuOpen"
id="site-mobile-menu"
class="border-t border-white/10 bg-black px-6 pb-6 md:hidden"
>
<div class="flex flex-col gap-4 pt-4">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
class="text-sm font-medium tracking-wide text-white transition-colors hover:text-brand-yellow"
@click="mobileMenuOpen = false"
>
{{ link.label }}
</a>
<div class="flex flex-col gap-3 pt-2">
<a
href="https://app.comfy.org"
class="rounded-full bg-brand-yellow px-5 py-2 text-center text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
COMFY CLOUD
</a>
<a
href="https://hub.comfy.org"
class="rounded-full border border-brand-yellow px-5 py-2 text-center text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
>
COMFY HUB
</a>
</div>
</div>
</div>
</nav>
</template>

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

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

View File

@@ -0,0 +1,81 @@
---
import { ClientRouter } from 'astro:transitions'
import Analytics from '@vercel/analytics/astro'
import '../styles/global.css'
interface Props {
title: string
description?: string
ogImage?: string
}
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
} = Astro.props
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const locale = Astro.currentLocale ?? 'en'
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageURL.href} />
<meta property="og:url" content={canonicalURL.href} />
<meta property="og:locale" content={locale} />
<meta property="og:site_name" content="Comfy" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Google Tag Manager -->
<script is:inline>
;(function (w, d, s, l, i) {
w[l] = w[l] || []
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' })
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : ''
j.async = true
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-NP9JM6K7')
</script>
<ClientRouter />
</head>
<body class="bg-black text-white font-inter antialiased">
<!-- Google Tag Manager (noscript) -->
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-NP9JM6K7"
height="0"
width="0"
style="display:none;visibility:hidden"
></iframe>
</noscript>
<slot />
<Analytics />
</body>
</html>

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"]
}

19
apps/website/vercel.json Normal file
View File

@@ -0,0 +1,19 @@
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=()"
}
]
}
]
}

View File

@@ -0,0 +1,92 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /See Errors/i }).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /Dismiss/i }).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})

View File

@@ -0,0 +1,111 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
// LinearControls requires hasOutputs to be true. Serialize the current
// graph, inject linearData with output node IDs, then reload so the
// appModeStore picks up the outputs via its activeWorkflow watcher.
await comfyPage.page.evaluate(async () => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
// Serialize, inject linearData, and reload to sync stores
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: [], outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
})
await comfyPage.nextFrame()
// Toggle to app mode via the command which sets canvasStore.linearMode
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
).toBeVisible({ timeout: 5000 })
})
test('Returns to graph mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await enterGraphMode(comfyPage)
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).not.toBeVisible()
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await expect(comfyPage.canvas).not.toBeVisible()
})
})

View File

@@ -0,0 +1,85 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('@canvas Selection Rectangle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('Ctrl+A selects all nodes', async ({ comfyPage }) => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(totalCount).toBeGreaterThan(0)
// Use canvas press for keyboard shortcuts (doesn't need click target)
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
await comfyPage.page
.getByText('Load Checkpoint')
.click({ modifiers: ['Control'] })
// Then deselect remaining via Escape or programmatic clear
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
test('Single click selects one node', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: ['Control']
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
})
test('Selected nodes have visual indicator', async ({ comfyPage }) => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
await expect(checkpointNode).toHaveClass(/outline-node-component-outline/)
})
test('Drag-select rectangle selects multiple nodes', async ({
comfyPage
}) => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
expect(totalCount).toBeGreaterThan(1)
})
})

View File

@@ -0,0 +1,101 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { ComfyPage } from '../fixtures/ComfyPage'
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
canvas.setDirty(true, true)
}, nodePos)
await comfyPage.nextFrame()
await nodeRef.click('title')
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const initialCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="properties-panel"]')
).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
).toBeVisible()
})
test('delete button removes multiple selected nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const initialCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
const newCount = await comfyPage.page.evaluate(
() => window.app!.graph!._nodes.length
)
expect(newCount).toBe(initialCount - 2)
})
})

View File

@@ -22,13 +22,12 @@ async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
if (!canvas.graph) return
canvas.setGraph(canvas.graph.rootGraph)
})
await comfyPage.nextFrame()
}
@@ -36,6 +35,10 @@ test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Auto-promotion on Convert to Subgraph', () => {
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
comfyPage
@@ -86,10 +89,18 @@ test.describe(
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select the SaveImage node (id 9 in default workflow)
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
const savePos = await saveNode.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
canvas.setDirty(true, true)
}, savePos)
await comfyPage.nextFrame()
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
@@ -251,7 +262,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -285,7 +296,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -331,7 +342,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -366,7 +377,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -397,7 +408,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await exitSubgraphToParent(comfyPage)
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -478,10 +489,18 @@ test.describe(
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select SaveImage (id 9)
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
const savePos = await saveNode.getPosition()
await comfyPage.page.evaluate((pos) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2
canvas.setDirty(true, true)
}, savePos)
await comfyPage.nextFrame()
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()

View File

@@ -0,0 +1,73 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Toast Notifications', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
async function triggerErrorToast(comfyPage: {
page: { evaluate: (fn: () => void) => Promise<void> }
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: 'Test execution error',
life: 30000
})
})
await comfyPage.nextFrame()
}
test('Error toast appears when triggered', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
test('Toast shows correct error severity class', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
const errorToast = comfyPage.page.locator(
'.p-toast-message.p-toast-message-error'
)
await expect(errorToast.first()).toBeVisible()
})
test('Toast can be dismissed via close button', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
const closeButton = comfyPage.page.locator('.p-toast-close-button').first()
await closeButton.click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
})
test('All toasts cleared via closeToasts helper', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
await comfyPage.toast.closeToasts()
expect(await comfyPage.toast.getVisibleToastCount()).toBe(0)
})
test('Toast error count is accurate', async ({ comfyPage }) => {
await triggerErrorToast(comfyPage)
await expect(
comfyPage.page.locator('.p-toast-message.p-toast-message-error').first()
).toBeVisible()
const errorCount = await comfyPage.toast.getToastErrorCount()
expect(errorCount).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -30,6 +30,17 @@ const config: KnipConfig = {
'packages/ingest-types': {
project: ['src/**/*.{js,ts}'],
entry: ['src/index.ts']
},
'apps/website': {
entry: [
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.mjs'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
}
},
ignoreBinaries: ['python3', 'gh', 'generate'],

View File

@@ -0,0 +1,46 @@
/*
* Design System Base — Brand tokens + fonts only.
* For marketing sites that don't use PrimeVue or the node editor.
* Import the full style.css instead for the desktop app.
*/
@import './fonts.css';
@theme {
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-white: #ffffff;
--color-black: #000000;
/* Brand Colors */
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
}

1640
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
@@ -47,6 +48,7 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
@@ -55,6 +57,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4