Compare commits

..

34 Commits

Author SHA1 Message Date
Jin Yi
a6e5ff1b37 fix: load API format workflows with missing node types 2026-03-10 12:13:56 +09:00
Dante
2ef354447d feat: add Storybook stories for Slider components (#9634)
## Summary
- Add Storybook stories for `Slider` component matching Figma design
system variants 1:1
- Stories at `Components/Slider`: **Default** and **Disabled**
- Add hover background
(`hover:bg-component-node-widget-background-hovered`) to
`WidgetInputNumberSlider` only

## Figma reference
[Comfy Design System —
Slider](https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=2-9718&m=dev)

## Scope decisions
- **Hover**: Added to `WidgetInputNumberSlider.vue` only — other widgets
need separate verification before applying
- **Invalid**: Not implemented in `Slider.vue` — excluded until
component supports it

## Files changed
- `src/components/ui/slider/Slider.stories.ts` — new
-
`src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberSlider.vue`
— add hover background

## Test plan
- [ ] `pnpm storybook` — verify Default and Disabled render under
`Components/Slider`
- [ ] Hover background visible on slider widget in app (e.g. KSampler
`cfg` slider)
- [ ] Other widget inputs (text, textarea, select) unchanged
- [ ] `pnpm typecheck` — passes
- [ ] `pnpm lint` — passes

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:49:47 +09:00
Hunter
55789ef0fb Redirect authenticated users from signup page to cloud (#9691)
## Summary

When a logged-in user navigates to `/cloud/signup`, they are now
redirected to `cloud-user-check` (which handles survey or main page
routing).

This mirrors the existing `beforeEnter` guard on the `cloud-login`
route. The `switchAccount` query param bypass is preserved for
consistency.

## Changes

- Added `beforeEnter` guard to the `cloud-signup` route in
`onboardingCloudRoutes.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9691-Redirect-authenticated-users-from-signup-page-to-cloud-31f6d73d365081e08cb5c3360a862a37)
by [Unito](https://www.unito.io)
2026-03-09 22:38:49 -04:00
Jin Yi
7add2c03e9 feat: unify search components by replacing SearchBox/SearchBoxV2 with SearchInput (#9644)
## Summary

Replace legacy `SearchBox` (PrimeVue) and `SearchBoxV2` with the unified
`SearchInput` (reka-ui) component across all consumers.

## Changes

- **What**: Remove `SearchBox.vue`, `SearchBoxV2.vue`, their tests and
stories. Migrate all 14 consumers to `SearchInput`. Move layout classes
to `ComboboxRoot` for proper flex sizing. Extract filter button/chips in
`NodeLibrarySidebarTab`. Standardize modal search width to `flex-1
max-w-lg`.
- **Dependencies**: None new — `SearchInput` already existed using
reka-ui

## Review Focus

- `NodeLibrarySidebarTab.vue`: filter button and `SearchFilterChip`
rendering moved outside the search component
- `SearchInput.vue`: `className` now applied to `ComboboxRoot` instead
of `ComboboxAnchor` for correct flex layout
- Modal dialogs (`WorkflowTemplateSelectorDialog`, `AssetBrowserModal`,
`SampleModelSelector`) unified to `flex-1 max-w-lg`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9644-feat-unify-search-components-by-replacing-SearchBox-SearchBoxV2-with-SearchInput-31e6d73d365081ebac55cb265f33b631)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-10 11:30:25 +09:00
Jin Yi
c81bc8400c fix: virtual scroll pagination not working in media asset list view (#9646)
## Summary

Fix virtual scroll pagination not triggering in media asset panel list
view.

## Changes

**What**: `VirtualGrid` in `AssetsSidebarListView` was missing
`maxColumns=1` and had an incorrect default item height (200px vs actual
~48px). Without `maxColumns`, `cols` was calculated as
`floor(containerWidth / 200)` (e.g. 2), causing the row count to be
halved and `isNearEnd` to never fire correctly. Added `:max-columns="1"`
and `:default-item-height="48"` to fix pagination. Added regression
tests to `VirtualGrid.test.ts`.

## Review Focus

The root cause: `VirtualGrid.cols` computed as `floor(width/200)`
instead of `1` for single-column list layout, breaking spacer heights
and `approach-end` detection.

Test covers both the fix (approach-end fires with maxColumns=1) and the
bug reproduction (does not fire without it).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9646-fix-virtual-scroll-pagination-not-working-in-media-asset-list-view-31e6d73d3650813d973ad19638ad6933)
by [Unito](https://www.unito.io)
2026-03-10 11:29:42 +09:00
AustinMroz
af5a72021b Use preview downscaling in fewer places (#9678)
Thumbnail downscaling is currently being used in more places than it
should be.
- Nodes which display images will display incorrect resolution
indicators
<img width="255" height="372" alt="image"
src="https://github.com/user-attachments/assets/674790b6-04c8-4db0-84c2-2fa2dbaf123d"
/> <img width="255" height="372" alt="image"
src="https://github.com/user-attachments/assets/1dbe751b-7462-4408-9236-9446b005f5fc"
/>

This is particularly confusing with output nodes, which claim the output
is not of the intended resolution
- The "Download Image" and "Open Image" context menu actions will
incorrectly download the downscaled thumbnail.
- The assets panel will incorrectly display the thumbnail resolution as
the resolution of the output
- The lightbox (zoom) of an image will incorrectly display a downscaled
thumbnail.

This PR is a quick workaround to staunch the major problems
- Nodes always display full previews.
- Resolution downscaling is applied on the assert card, not on the
assetItem itself
- Due to implementation, this means that asset cards will still
incorrectly show the resolution of the thumbnail instead of the size of
the full image.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 16:03:32 -07:00
Hunter
4e5bb3e540 fix: dispatch cloud build on synchronize for preview-labeled PRs (#9636)
## Summary

Cloud build dispatch was only triggering on the `labeled` event, not on
subsequent pushes to PRs that already had a preview label.

## Changes

- **What**: Add `synchronize` to `pull_request` event types and update
the `if` condition to support all three preview labels (`preview`,
`preview-cpu`, `preview-gpu`). For `labeled` events, check the added
label name; for `synchronize` events, check existing PR labels.

## Review Focus

The `if` condition now branches on `github.event.action` to use the
correct label-checking mechanism for each event type.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9636-fix-dispatch-cloud-build-on-synchronize-for-preview-labeled-PRs-31e6d73d3650814e9069e37d6199ffc9)
by [Unito](https://www.unito.io)
2026-03-09 15:55:53 -07:00
AustinMroz
2ccfb822b4 Restore hiding of linked inputs in app mode (#9671)
As a temporary fix for widgets being incorrectly hidden, #9669 allowed
all disabled widgets to be displayed.

This PR provides a more robust implementation to derive whether the
widget, as would be displayed from the root graph, is disabled.

Potential regression:
- Drag drop handlers are applied on node, not widgets. A subgraph
containing a "Load Image" node, does not allow dragging and dropping an
image onto the subgraphNode in order to load it. Because app mode
widgets would display from the original owning node prior to this PR,
these drag/drop handlers would apply. Placing "Load Image" nodes. I
believe this change makes behavior more consistent, but it warrants
consideration.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9671-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d365081688e37fbb931f3af68)
by [Unito](https://www.unito.io)
2026-03-09 13:18:05 -07:00
jaeone94
370003da94 fix: add isGraphReady guard to prevent premature graph access error logs (#9672)
## Summary
Adds `isGraphReady` getter to `ComfyApp` and uses it in
`executionErrorStore` guards to prevent false 'ComfyApp graph accessed
before initialization' error logs during early store evaluation.

## Changes
- **What**: Added `isGraphReady` boolean getter to `ComfyApp` that
safely checks graph initialization without triggering the `rootGraph`
getter's error log. Updated 5 guard sites in `executionErrorStore` to
use `app.isGraphReady` instead of `app.rootGraph`.
- **Why**: The `rootGraph` getter logs an error when accessed before
initialization. Computed properties and watch callbacks in
`executionErrorStore` are evaluated early (before graph init), causing
false error noise in the console.

## Review Focus
- `isGraphReady` is intentionally minimal — just
`!!this.rootGraphInternal` — to avoid duplicating the error-logging
behavior of `rootGraph`
- The `watch(lastNodeErrors, ...)` callback now checks `isGraphReady` at
the top and early-returns, consistent with the computed property pattern

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9672-fix-add-isGraphReady-guard-to-prevent-premature-graph-access-error-logs-31e6d73d365081be8e1fc77114ce9382)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 13:15:04 -07:00
Alexander Brown
3b5af4960f fix: show load widget inputs in media dropdown (#9670)
Main targeted, built on
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9551

## Summary

Fix Load Image/Load Video input dropdown tabs not showing available
input assets in Vue node select dropdown.

## Changes

- **What**: Keep combo widget `options` object identity while exposing
dynamic `values` for cloud/remote combos.
- **What**: Remove temporary debug logging and restore clearer dropdown
filter branching.
- **What**: Remove stale `searcher`/`updateKey` prop plumbing in
dropdown menu/actions and update related tests.

## Review Focus

Verify `Load Image` / `Load Video` Inputs tab behavior and confirm
cloud/remote combo option values still update correctly.

Relates to #9551

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9670-fix-show-load-widget-inputs-in-media-dropdown-31e6d73d36508148b845e18268a60c2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 12:49:47 -07:00
Christian Byrne
46895ee1a9 docs: add release process guide (#9548)
Adds a concise guide to `docs/release-process.md` explaining how the
release workflows interact, with focus on the version semantics that
differ between minor and patch bumps.

Key sections:
- How minor bumps freeze the previous minor into `core/` and `cloud/`
branches
- How patch bumps on `main` vs `core/X.Y` differ (published vs draft
releases)
- Why unreleased commits are dual-homed when a minor bump happens
- Summary table, backporting, publishing, and bi-weekly automation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9548-docs-add-release-process-guide-31d6d73d365081f2bdaace48a7cb81ae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 12:38:40 -07:00
AustinMroz
7f0472fde4 Always use interior nodeId for app mode (#9669)
App mode stores the state of selected widgets as a tuple of `[NodeId,
WidgetName]`. With recent subgraph changes, for a given node,
`widget.name` will no longer uniquely resolve to a single widget.

- From both Vue and Litegraph, selecting an input for display in App
mode will now resolve the NodeId of the node which owns the widget
instead of the selected node.
- When displaying selections in litegraph, if the NodeId does not exist
in the current graph, instead of resolving the actual node the rootGraph
is searched for any subgraphNode which contains a view matching the
`[NodeId, WidgetName]` pair.
- When displaying widgets in App mode, the widget is always set as being
a view of the real widget (This means that they will not display a
purple promotion border.

Known Issue:
- These same subgraph changes made it so that a widget can be linked
without being disabled. This PR makes it so widgets which have been
linked instead display normally under the assumption that they are
incorrectly marked as disabled. As disabled widgets can not be selected
as inputs, this should handle normal usage fine, but a better solution
is being investigated

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9669-Always-use-interior-nodeId-for-app-mode-31e6d73d365081f8a918d0e43cb659ee)
by [Unito](https://www.unito.io)
2026-03-09 11:36:33 -07:00
Alexander Brown
24ac6388d7 style: Update share icon to be a send icon instead (#9667)
## Summary

It's a plane!

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9667-style-Update-share-icon-to-be-a-send-icon-instead-31e6d73d365081919013ea1521d26f2c)
by [Unito](https://www.unito.io)
2026-03-09 17:00:53 +00:00
Alexander Brown
6b6049e48e fix: Add a tooltip to account for assets with really long names. (#9665)
## Summary

Simple tooltip, default show delay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9665-fix-Add-a-tooltip-to-account-for-assets-with-really-long-names-31e6d73d3650811f9057d1ec41e761b6)
by [Unito](https://www.unito.io)
2026-03-09 09:52:28 -07:00
AustinMroz
592f992d1d Even further app fixes (#9617)
- Allow dragging zoom pane with middle click
- Prevent selection of canvasOnly widgets
- These widgets would not display in app mode, so allow selection would
only cause confusion.
- Support displaying the error dialogue in app mode
- Add a somewhat involved mobile app mode error indication system
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/d8793bbd-fff5-4b2a-a316-6ff154bae2c4"
/> <img width="300" alt="image"
src="https://github.com/user-attachments/assets/cb88b0f6-f7e5-409e-ae43-f1348f946b19"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9617-Even-further-app-fixes-31d6d73d365081c891dfdfe3477cfd61)
by [Unito](https://www.unito.io)
2026-03-09 09:35:31 -07:00
jaeone94
76fd80aa98 fix: hide empty actionbar container and relocate error border to floating actionbar (#9657)
## Summary
When the actionbar is floating and has no docked buttons, the container
is now hidden (zero-width, transparent border) to avoid showing an empty
rounded box. Additionally, the error/destructive border is now applied
to the floating actionbar panel itself (via `ComfyActionbar`) instead of
the container, so it appears in the correct location when floating.

## Changes
- **TopMenuSection**: Added `hasDockedButtons` and
`isActionbarContainerEmpty` computed properties to detect when the
docked container has no visible buttons; `actionbarContainerClass`
computed hides the container by collapsing it when empty and floating,
while preserving the legacy drop zone via `:has(.border-dashed)` CSS
selector
- **TopMenuSection**: Error border
(`border-destructive-background-hover`) is now only applied to the
docked container when the actionbar is **not** floating
- **ComfyActionbar**: Accepts new `hasAnyError` prop and applies the
error border to the floating panel's `panelClass` when floating

## Review Focus
- The `has-[.border-dashed]` CSS selector restores the container visuals
when a legacy drag-target element is present inside it — verify this
works as expected
- Error border placement: docked mode shows border on container,
floating mode shows border on the fixed panel

## Screenshots


https://github.com/user-attachments/assets/75caabac-e391-4bfd-b4dc-62d564e55d37
2026-03-09 21:24:00 +09:00
Hunter
63c36d3f2f feat: display original asset names instead of hashes in assets panel (#9626)
## Problem
Output assets in the assets panel show content hashes (e.g.,
`a1b2c3d4.png`) instead of display names (e.g., `ComfyUI_00001_.png`).

## Root Cause
Cloud inference replaces `filename` with the content hash in the output
transform pipeline. The hashed filename gets stored in the jobs table's
`preview_output` JSONB. The frontend uses this hash as the display name.

## Solution
- Add `display_name` field to `AssetItem` schema and `ResultItemImpl`
- Backend (cloud PR) joins job→assets table to resolve the original name
and injects `display_name` into job responses
- Frontend prefers `display_name` over `name` **only for display text
and download filenames**
- `asset.name` remains unchanged (the hash) for URLs, drag-to-canvas,
export filters, and output key dedup

## Backwards Compatible
- OSS: `display_name` is undefined, falls back to `asset.name` (which is
already the real filename in OSS)
- Cloud pre-deploy: `display_name` absent from API, falls back
gracefully
- Old jobs with no assets: `display_name` not injected, no change

## Cloud PR
https://github.com/Comfy-Org/cloud/pull/2747



https://github.com/user-attachments/assets/8a4c9cac-4ade-4ea2-9a70-9af240a56602
2026-03-09 01:06:28 -04:00
pythongosssss
892a9cf2c5 fix: prevent showing outputs in app mode when no output nodes configured (#9625)
## Summary

After a user runs the workflow once in graph mode, switching to app mode
with no app built, incorrectly showed the app mode outputs view instead
of the intro screen

## Changes

- **What**: don't try and select outputs if no outputs & filter out all
outputs when nothing chosen
2026-03-08 17:36:15 -07:00
Comfy Org PR Bot
308c22efc6 1.42.2 (#9629)
Patch version increment to 1.42.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9629-1-42-2-31e6d73d365081faa106d97ae431e2e6)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-08 17:24:52 -07:00
Hunter
5728d240da fix: restore backend outputs_count for asset sidebar multi-output badge (#9627)
## Summary

Fix regression from PR #9535 where the multi-output count badge stopped
appearing in the asset sidebar.

## Root Cause

PR #9535 changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (backend-provided total) to
`task.previewableOutputs.length`. However, `TaskItemImpl` constructed
from a job listing only has the single `preview_output`, so
`previewableOutputs.length` is always **1** — the multi-output badge
never appears.

## Fix

Use the backend-provided `outputs_count` (via `task.outputsCount`) with
fallback to `task.previewableOutputs.length` when unavailable. This
restores the correct count while preserving the fallback for jobs that
don't have `outputs_count` from the server.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9627-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-badge-31d6d73d36508160b93fd03af4a01aa3)
by [Unito](https://www.unito.io)
2026-03-08 13:17:22 -07:00
Kelly Yang
acf2f4280c fix(maskeditor): make brush size slider logarithmic (#8097) (#9534)
## Summary
fix #8097.

This PR shifts the Mask Editor Brush Size slider from a linear scale to
a logarithmic (exponential) scale. Previously, the linear 1-250 range
heavily clumped the usable, small "fine-detail" brush sizes (e.g., 1px
to 20px) into the very first 10% of the slider, making it extremely
difficult to select precise sizes with the mouse.

This update borrows UX paradigms from other standard image editors like
Photoshop and GIMP, which map their scale entry widgets on an
exponential curve.

## GIMP Source
By inspecting the official **GIMP** source code under
`libgimpwidgets/gimpscaleentry.c`, we can see this exact mathematical
relationship being utilized when the logarithmic property is marked TRUE
on a brush radius adjustment widget:

```
// Mapping visual slider to internal value
value = gtk_adjustment_get_lower(...) + exp(t);
// Mapping internal value to visual slider
t = log (value - gtk_adjustment_get_lower(...) + 0.1);
```


https://github.com/user-attachments/assets/6d59ff12-f623-42cc-a52b-84147e9bb90b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9534-fix-maskeditor-make-brush-size-slider-logarithmic-8097-31c6d73d365081118508e8363e0c5312)
by [Unito](https://www.unito.io)
2026-03-08 09:11:19 -07:00
Comfy Org PR Bot
7ad6994d01 1.42.1 (#9546)
Patch version increment to 1.42.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9546-1-42-1-31d6d73d365081a781fdebfef024a7cd)
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>
2026-03-07 18:28:06 -08:00
Christian Byrne
2829f78579 fix: use previewable output count for asset sidebar badge (#9535)
## Summary

Fix asset sidebar badge showing inflated output count by using
previewable outputs length instead of raw server count.

## Changes

- **What**: Changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (includes all output types: text, JSON, custom data)
to `task.previewableOutputs.length` (only image, video, audio, 3D). The
badge now matches what users actually see in the expanded view.

## Review Focus

One-line change. The `task.previewableOutputs` array is already computed
on the line immediately below and used for `allOutputs`, so this
introduces no new computation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9535-fix-use-previewable-output-count-for-asset-sidebar-badge-31c6d73d365081c49161caec64cf3921)
by [Unito](https://www.unito.io)
2026-03-07 18:03:21 -08:00
pythongosssss
c4156d7059 feat/fix: App mode further updates (#9545)
## Summary

Additional updates

## Changes

- **What**: 
- Share widget rename functionality with properties panel implementation
- Add hammer icon to builder mode tabs
- Change (!) to (i) on app builder info sections

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9545-feat-fix-App-mode-further-updates-31c6d73d36508104aaa9c5f1e6205a0b)
by [Unito](https://www.unito.io)
2026-03-07 16:03:55 -08:00
Christian Byrne
725a0a2b89 fix: remove timeouts from error toasts so they persist until dismissed (#9543)
## Summary

Remove `life` (timeout) property from all error-severity toast calls so
they persist until manually dismissed, preventing users from missing
important error messages.

## Changes

- **What**: Removed `life` property from 86 error toast calls across 46
files. Error toasts now use PrimeVue's default behavior (no
auto-dismiss). Non-error toasts (success, warn, info) are unchanged.
- Also fixed a pre-existing lint issue in `TaskListPanel.vue` (`import {
t } from '@/i18n'` → `useI18n()`)

## Review Focus

- One conditional toast in `useMediaAssetActions.ts` intentionally keeps
`life` because its severity alternates between `warn` and `error`

Fixes
https://www.notion.so/comfy-org/Implement-Remove-timeouts-for-all-error-toasts-31b6d73d365081cead54fddc77ae7c3d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9543-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismissed-31c6d73d365081fa8d30f6366e9bfe38)
by [Unito](https://www.unito.io)
2026-03-07 15:08:13 -08:00
Alexander Brown
8a5bcde168 fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9542)
## Summary

Fix non-widget inputs on nested subgraphs appearing twice — once as
slots and once as unresolved button widgets.

## Changes

- **What**: Add `getTargetWidget()` guard in the `isSubgraphNode()`
branch of `resolveSubgraphInputTarget`, matching the existing check for
non-subgraph nodes. Non-widget inputs (e.g. AUDIO, IMAGE) now return
`undefined` instead of a bogus promotion entry.

## Review Focus

`resolveSubgraphInputTarget` had an asymmetry: the non-subgraph branch
checked `getTargetWidget()` before returning, but the `isSubgraphNode()`
branch returned unconditionally for every input. For nested subgraphs
where non-widget slots are linked through to inner SubgraphNode inputs,
this created `PromotedWidgetView` entries that failed `resolveDeepest()`
(falling back to `type: 'button'`), while the inputs also rendered as
normal slot circles since `input.widget` was never set by
`_resolveInputWidget` (which correctly skipped them).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9542-fix-prevent-non-widget-inputs-on-nested-subgraphs-from-appearing-as-button-widgets-31c6d73d3650816387c3f97f0385e762)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 22:58:59 +00:00
AustinMroz
83ffaf30c8 Yet further app fixes (#9523)
- Prevent selection of note nodes
- Prevents selection  of nodes with errors
- A bit broader than the reported "can select missing nodes". A node
with an error is a node that can not execute and thus can not be used in
an app.
- Updates the typeform survey
- Add a collapsible list of all api nodes(/prices) contained in an app.
  - Needs to be prettied up for mobile still.
<img width="322" height="751" alt="image"
src="https://github.com/user-attachments/assets/ebfeeada-9b80-488e-88d6-feaa8bd53629"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9523-Yet-further-app-fixes-31c6d73d365081de9150fbf2d3ec54dd)
by [Unito](https://www.unito.io)
2026-03-07 14:22:15 -08:00
Christian Byrne
2875f897dc fix: remove workspace switching confirmation dialog (#9250)
## Summary

Remove the workspace switching confirmation dialog since switching
workspaces no longer discards unsaved changes.

## Changes

- **What**: Remove `hasUnsavedChanges` check, `dialogService.confirm`
call, and unused imports (`useI18n`, `useWorkflowStore`,
`useDialogService`) from `useWorkspaceSwitch`. Rename
`switchWithConfirmation` to `switchWorkspace`. Update callers
(`WorkspaceSwitcherPopover.vue`, `InviteAcceptedToast.vue`). Remove
`workspace.unsavedChanges` i18n entries from all 12 locale files.
Simplify tests to cover core switching behavior only.

## Review Focus

The confirmation dialog was showing inaccurate information (warning
about discarding unsaved changes when that no longer happens). This is a
pure removal with no new behavior.

<!-- Pipeline-Ticket: COM-15441 -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9250-fix-remove-workspace-switching-confirmation-dialog-3136d73d365081d3b959da22e8f151d1)
by [Unito](https://www.unito.io)
2026-03-07 14:19:05 -08:00
pythongosssss
ec129de63d fix: Prevent corruption of workflow data due to checkState during graph loading (#9531)
## Summary

During workflow loading, the workflow data & active workflow object can
be out of sync, meaning any checkState calls will overwrite data into
the wrong workflow.

Recreation steps:
* Open 2-3 workflows
* Enter builder mode > select step
* Select some different inputs on each
* Quickly tap the shift key (this triggers checkState) while switching
tabs
* After a while, you'll see the wrong inputs on the workflows

Alternatively, register an extension that guarantees to call checkState
during the bad phase, run this in browser devtools and switch tabs:
```
window.app.registerExtension({
  name: 'bad',
  async afterConfigureGraph() {
    window.app.extensionManager.workflow.activeWorkflow.changeTracker.checkState()
  }
})
```

## Changes

- **What**: 
- Add loading graph flag
- Prevent checkState calls while loading
- Prevent app mode data sync while loading

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9531-fix-Prevent-corruption-of-workflow-data-due-to-checkState-during-graph-loading-31c6d73d365081e2ab91d9145bf1d025)
by [Unito](https://www.unito.io)
2026-03-07 12:44:12 -08:00
pythongosssss
1687ca93b3 feat: Integrated tab UI updates (#8516)
## Summary
Next iteration of the integrated tab/top menu

## Changes
- **What**:  
- make integrated default, rename old to legacy
- move feedback to integrated
- fix user icon shapes
- remove comfy cloud text in top bar, move to canvas stats
- add chevron to C logo menu
- move help back to sidebar
   - remove now unused help top positioning code

## Screenshots (if applicable)
<img width="428" height="148" alt="image"
src="https://github.com/user-attachments/assets/725025b7-4982-4f61-be11-8aabb0a1faff"
/>
<img width="264" height="187" alt="image"
src="https://github.com/user-attachments/assets/91fa5e92-df08-4467-9bc5-50a614d9b8aa"
/>
<img width="1169" height="220" alt="image"
src="https://github.com/user-attachments/assets/68c81bea-0cff-48df-8303-a6231a1d2fc4"
/>
<img width="242" height="207" alt="image"
src="https://github.com/user-attachments/assets/5a10f40e-83ae-44c3-9434-3dbe87ba30e2"
/>
<img width="302" height="222" alt="image"
src="https://github.com/user-attachments/assets/27fcc638-5fff-4302-9a1f-066227aafd86"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 11:20:01 -08:00
pythongosssss
5bb742ac3a feat/fix: App mode QA fixes (#9530)
## Summary

Additional updates for app mode

## Changes

- **What**:
- Reposition toolbar button in app mode so it doesnt jump between modes
- Move node name to under title on input/output selection
- Change delete asset button text
- Change exit builder button icon

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9530-feat-fix-App-mode-QA-fixes-31c6d73d365081738b5cc5beaf2cbd41)
by [Unito](https://www.unito.io)
2026-03-07 18:54:13 +00:00
Kelly Yang
ca2d61f393 use getLoad3dAsync (#9520)
## Summary

Applying `useLoad3dService().getLoad3dAsync` to fix the broken 3D viewer
scene.

Fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9495

## Screenshots 

before

<img width="3456" height="2168" alt="665d1b83bf8b23a9ddde1411a34c8df9"
src="https://github.com/user-attachments/assets/6cb190f4-ef13-4fd3-a0c5-2360f056da55"
/>


after

<img width="5120" height="2638" alt="image"
src="https://github.com/user-attachments/assets/154b1a98-bd71-41e2-839d-f0f1f7e5e72e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9520-use-getLoad3dAsync-31c6d73d365081cf8c24cd89b545ccb4)
by [Unito](https://www.unito.io)
2026-03-07 09:22:35 -08:00
Benjamin Lu
750a2d23e0 chore: standardize on Node 24 (#9521)
## Summary

Standardize the repo's Node contract on 24 while centralizing workflow
resolution through `.nvmrc` so local setup, CI, and package metadata
stay aligned from one version file.

## Changes

- **What**: Add `package.json` `engines.node = 24.x`, switch every
`actions/setup-node` workflow in the repo to `node-version-file:
'.nvmrc'`, and update contributor and Playwright docs to point to
`.nvmrc` as the Node source of truth.

## Review Focus

The workflow behavior should be unchanged apart from sourcing the Node
version from `.nvmrc` instead of repeating literals like `20`, `22`,
`24.x`, or `lts/*`. GitHub's formatter also moved the new `engines`
block to the package metadata section near the end of `package.json`.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-07 09:06:10 +00:00
Comfy Org PR Bot
6d90bf3537 1.42.0 (#9522)
Minor version increment to 1.42.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9522-1-42-0-31c6d73d3650816faa33df8b4dde26fd)
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>
2026-03-06 23:11:18 -08:00
328 changed files with 4081 additions and 10694 deletions

View File

@@ -38,9 +38,6 @@ TEST_COMFYUI_DIR=/home/ComfyUI
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Update electron types

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -26,7 +26,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -14,7 +14,7 @@ on:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled]
types: [labeled, synchronize]
workflow_dispatch:
permissions: {}
@@ -26,11 +26,18 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch when the 'preview' label is added.
# For pull_request events, only dispatch for preview labels.
# - labeled: fires when a label is added; check the added label name.
# - synchronize: fires on push; check existing labels on the PR.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
github.event.label.name == 'preview')
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
runs-on: ubuntu-latest
steps:
- name: Build client payload
@@ -43,10 +50,6 @@ jobs:
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
# Derive variant from all PR labels (default to cpu for frontend-only previews)
VARIANT="cpu"
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies for analysis tools

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
node-version-file: '.nvmrc'
- name: Download PR metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
- name: Read desktop-ui version
id: get_version

View File

@@ -91,7 +91,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: 'frontend/.nvmrc'
- name: Install dependencies
working-directory: frontend

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
- name: Check version bump type
id: check_version

View File

@@ -26,7 +26,7 @@ jobs:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Get current version

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org

View File

@@ -22,7 +22,7 @@ jobs:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Get current version

View File

@@ -149,7 +149,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
- name: Bump version
id: bump-version

View File

@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Bump desktop-ui version

View File

@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies for analysis tools

1
.gitignore vendored
View File

@@ -26,7 +26,6 @@ dist-ssr
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
.claude/worktrees
CLAUDE.local.md
# Editor directories and files

View File

@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v24) and pnpm
- Node.js (see `.nvmrc`, currently v24) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)

View File

@@ -27,7 +27,8 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
Then, set up the Chromium test driver:
```bash
pnpm exec playwright install chromium --with-deps

View File

@@ -1,47 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "KSampler",
"pos": [500, 50],
"size": [315, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,44 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Pasted group is offset from original position', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const positions = await comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
expect(positions).toHaveLength(2)
const dx = Math.abs(positions[0].x - positions[1].x)
const dy = Math.abs(positions[0].y - positions[1].y)
expect(dx).toBeCloseTo(50, 0)
expect(dy).toBeCloseTo(15, 0)
})
})

View File

@@ -1,58 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Image paste priority over stale node metadata',
{ tag: ['@node'] },
() => {
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBe(2)
// Copy the KSampler node (puts data-metadata in clipboard)
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
// Select the LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
// Simulate pasting when clipboard has stale node metadata (text/html
// with data-metadata) but no image file items. This replicates the bug
// scenario: user copied a node, then copied a web image (which replaces
// clipboard files but may leave stale text/html with node metadata).
await comfyPage.page.evaluate(() => {
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
const base64 = btoa(JSON.stringify(nodeData))
const html =
'<meta charset="utf-8"><div><span data-metadata="' +
base64 +
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Node count should remain the same — stale node metadata should NOT
// be deserialized when a media node is selected.
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(finalCount).toBe(initialCount)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,104 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { Position } from '../fixtures/types'
type NodeSnapshot = { id: number } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
x: n.pos[0],
y: n.pos[1]
}))
)
}
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
if (!node) return
return {
x: node.pos[0],
y: node.pos[1]
}
}, nodeId)
}
async function expectNodePositionStable(
comfyPage: ComfyPage,
initial: NodeSnapshot,
mode: string
) {
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.x ?? Number.NaN
},
{ message: `node ${initial.id} x drifted in ${mode} mode` }
)
.toBeCloseTo(initial.x, 1)
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.y ?? Number.NaN
},
{ message: `node ${initial.id} y drifted in ${mode} mode` }
)
.toBeCloseTo(initial.y, 1)
}
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
await comfyPage.nextFrame()
}
test.describe(
'Renderer toggle stability',
{ tag: ['@node', '@canvas'] },
() => {
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
comfyPage
}) => {
const TOGGLE_COUNT = 5
const initialPositions = await getAllNodePositions(comfyPage)
expect(initialPositions.length).toBeGreaterThan(0)
for (let i = 0; i < TOGGLE_COUNT; i++) {
await setVueMode(comfyPage, true)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`Vue toggle ${i + 1}`
)
}
await setVueMode(comfyPage, false)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`LiteGraph toggle ${i + 1}`
)
}
}
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,77 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Subgraph progress clear on navigation',
{ tag: ['@subgraph'] },
() => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId!)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,102 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

62
docs/release-process.md Normal file
View File

@@ -0,0 +1,62 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

View File

@@ -24,7 +24,6 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const

2
global.d.ts vendored
View File

@@ -1,5 +1,4 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
@@ -36,7 +35,6 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -47,60 +47,6 @@
border: 0;
}
</style>
<style>
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
index.html instead of CSS on cloud/ephemeral environments */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}
</style>
<link rel="manifest" href="manifest.json" />
</head>

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.13",
"version": "1.42.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -195,6 +195,9 @@
"zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": "24.x"
},
"pnpm": {
"overrides": {
"vite": "catalog:"

View File

@@ -15,10 +15,10 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent}]");
@custom-variant touch (@media (hover: none));

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>

Before

Width:  |  Height:  |  Size: 236 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 20.1046 3.79594 21 4.77778 21H11V3H4.77778C3.79594 3 3 3.89543 3 5M3 19V5M3 19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21M3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M11 1L11 23M21 15L17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914L14 13M11 16L6 21M14 3H19.1538C20.1734 3 21 3.89543 21 5V19C21 20.1046 20.1734 21 19.1538 21H14V3ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 760 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 14.9999L17.914 11.9139C17.5389 11.539 17.0303 11.3284 16.5 11.3284C15.9697 11.3284 15.4611 11.539 15.086 11.9139L12.6935 14.3064M14.5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V13M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM2.03125 18.6735C1.98958 18.5613 1.98958 18.4378 2.03125 18.3255C2.43708 17.3415 3.12595 16.5001 4.01054 15.9081C4.89512 15.3161 5.93558 15 7 15C8.06442 15 9.10488 15.3161 9.98946 15.9081C10.874 16.5001 11.5629 17.3415 11.9687 18.3255C12.0104 18.4378 12.0104 18.5613 11.9687 18.6735C11.5629 19.6575 10.874 20.4989 9.98946 21.0909C9.10488 21.683 8.06442 21.999 7 21.999C5.93558 21.999 4.89512 21.683 4.01054 21.0909C3.12595 20.4989 2.43708 19.6575 2.03125 18.6735ZM8.49992 18.4995C8.49992 19.3278 7.82838 19.9994 6.99999 19.9994C6.17161 19.9994 5.50007 19.3278 5.50007 18.4995C5.50007 17.6711 6.17161 16.9995 6.99999 16.9995C7.82838 16.9995 8.49992 17.6711 8.49992 18.4995Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.5 13.7503L20.5 15.7503M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM21.5871 14.6562C21.8514 14.3919 22 14.0334 22 13.6596C22 13.2858 21.8516 12.9273 21.5873 12.6629C21.323 12.3986 20.9645 12.25 20.5907 12.25C20.2169 12.25 19.8584 12.3984 19.594 12.6627L12.921 19.3373C12.8049 19.453 12.719 19.5955 12.671 19.7523L12.0105 21.9283C11.9975 21.9715 11.9966 22.0175 12.0076 22.0612C12.0187 22.105 12.0414 22.1449 12.0734 22.1768C12.1053 22.2087 12.1453 22.2313 12.189 22.2423C12.2328 22.2533 12.2787 22.2523 12.322 22.2393L14.4985 21.5793C14.6551 21.5317 14.7976 21.4463 14.9135 21.3308L21.5871 14.6562Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9.5M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 15H4.33333C3.59695 15 3 14.403 3 13.6667V4.33333C3 3.59695 3.59695 3 4.33333 3H13.6667C14.403 3 15 3.59695 15 4.33333V11L12.9427 8.94263C12.6926 8.69267 12.3536 8.55225 12 8.55225C11.6464 8.55225 11.3074 8.69267 11.0573 8.94263L5 15M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M18 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M10 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V18M8.33333 7C8.33333 7.73638 7.73638 8.33333 7 8.33333C6.26362 8.33333 5.66667 7.73638 5.66667 7C5.66667 6.26362 6.26362 5.66667 7 5.66667C7.73638 5.66667 8.33333 6.26362 8.33333 7ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 17.9999V20.6666C10 21.403 10.597 21.9999 11.3333 21.9999H20.6667C21.403 21.9999 22 21.403 22 20.6666V11.3333C22 10.5969 21.403 9.99994 20.6667 9.99994H18M14 16.9999V18.6666L18 15.9999L16.6579 15.1052M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 21.9999L7 19.9999M7 19.9999H4C3.46957 19.9999 2.96086 19.7892 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 17.9999V16.9999M7 19.9999L5 17.9999M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,10 +0,0 @@
<svg width="182" height="148" viewBox="0 0 182 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2902_38097)">
<path d="M116 16H110V0H116V16ZM105 96H99V132H105V96ZM105 24H99V36H105V24ZM149 96H143V128H149V96ZM160 100H154V116H160V100ZM105 52H99V76H105V52ZM116 40H110V84H116V40ZM160 16H154V72H160V16ZM171 20H165V52H171V20ZM182 24H176V36H182V24ZM94 44H88V112H94V44ZM94 44H88V112H94V44ZM127 32H121V48H127V32ZM138 24H132V40H138V24ZM149 16H143V48H149V16ZM127 60H121V84H127V60ZM138 68H132V84H138V68ZM149 60H143V84H149V60ZM116 92H110V112H116V92ZM127 92H121V104H127V92ZM138 92H132V112H138V92ZM116 120H110V144H116V120ZM127 128H121V148H127V128ZM138 120H132V140H138V120ZM72 0H66V16H72V0ZM83 96H77V132H83V96ZM83 24H77V36H83V24ZM39 96H33V128H39V96ZM28 100H22V116H28V100ZM83 52H77V76H83V52ZM72 40H66V84H72V40ZM28 16H22V72H28V16ZM17 20H11V52H17V20ZM6 24H0V36H6V24ZM61 32H55V48H61V32ZM50 24H44V40H50V24ZM39 16H33V48H39V16ZM61 60H55V84H61V60ZM50 68H44V84H50V68ZM39 60H33V84H39V60ZM72 92H66V112H72V92ZM61 92H55V104H61V92ZM50 92H44V112H50V92ZM72 120H66V144H72V120ZM61 128H55V148H61V128ZM50 120H44V140H50V120Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_2902_38097">
<rect width="182" height="148" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,5 @@
<svg width="49" height="24" viewBox="0 0 49 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5243 11.7208C26.7584 11.955 26.7583 12.3351 26.5243 12.5694L23.9794 15.1153C23.7452 15.3495 23.3651 15.3493 23.1308 15.1153C22.8965 14.881 22.8965 14.501 23.1308 14.2667L24.6474 12.7501L17.0995 12.7501C16.7683 12.7499 16.4999 12.4807 16.4999 12.1495C16.5001 11.8184 16.7685 11.5501 17.0995 11.5499L24.6571 11.5499L23.1308 10.0235C22.8965 9.7892 22.8965 9.40919 23.1308 9.17489C23.3651 8.94127 23.7453 8.94083 23.9794 9.17489L26.5243 11.7208Z" fill="#8A8A8A"/>
<path d="M4.50779 3.61371C4.6735 3.55748 4.85537 3.56703 5.0156 3.64203L6.14353 4.16938L6.9199 3.685L6.99509 3.64496C7.17632 3.56122 7.38691 3.56099 7.57029 3.64692L8.95798 4.29633C9.2306 4.42419 9.34839 4.74924 9.22068 5.02192C9.09287 5.29443 8.76771 5.4121 8.49509 5.28461L7.30857 4.72797L6.5449 5.20551V6.97211H9.32127C9.55299 6.97221 9.76687 7.08954 9.89158 7.27973L9.93943 7.36567L11.2588 10.1918C11.2714 10.2189 11.282 10.2474 11.291 10.2758L11.3125 10.3627L11.9902 14.2407C12.042 14.5373 11.8435 14.8206 11.5469 14.8725C11.2505 14.924 10.9681 14.7254 10.916 14.4291L10.247 10.6049L9.06052 8.06293H6.5449V11.6079C6.54489 11.6553 6.53579 11.7007 6.52439 11.7446H7.03318C7.26977 11.7446 7.48688 11.8665 7.61033 12.0629L7.6572 12.1518L8.89548 14.9711C8.9258 15.0402 8.94492 15.1138 8.95115 15.1889L9.26951 19.0629C9.29388 19.3627 9.07125 19.6259 8.77146 19.6508C8.47137 19.6755 8.20837 19.4519 8.18357 19.1518L7.86912 15.3471L6.7656 12.8344H5.52048L4.12693 15.3715L3.81541 19.1518C3.79062 19.4519 3.52762 19.6755 3.22752 19.6508C2.92759 19.626 2.70508 19.3628 2.72947 19.0629L3.04685 15.1957L3.05662 15.1245C3.06994 15.0542 3.09436 14.9862 3.12888 14.9233L4.68064 12.0981L4.73045 12.02C4.85779 11.8482 5.05991 11.7448 5.27732 11.7446H5.47361C5.46224 11.7007 5.45409 11.6553 5.45408 11.6079V8.06293H3.24412L1.74509 10.6323L1.08103 14.1127C1.02439 14.4084 0.738116 14.6018 0.44236 14.5454C0.146978 14.4885 -0.0465728 14.2032 0.00974259 13.9077L0.687477 10.3598L0.718727 10.2485C0.732186 10.2126 0.748186 10.1772 0.767555 10.144L2.42088 7.31C2.54303 7.10073 2.76744 6.97221 3.00974 6.97211H5.45408V5.05121L4.72654 4.71039L3.50388 5.28461C3.23123 5.41225 2.90614 5.2945 2.7783 5.02192C2.65052 4.74922 2.76835 4.4242 3.04099 4.29633L4.43748 3.64203L4.50779 3.61371Z" fill="#8A8A8A"/>
<path d="M44.7027 5C46.7971 5 48.5097 6.71671 48.5097 8.84956V14.3804C48.5097 16.5133 46.7971 18.23 44.7027 18.23H35.3067C33.2122 18.23 31.4997 16.5133 31.4997 14.3804V8.84956C31.4997 6.71671 33.2122 5 35.3067 5H44.7027ZM35.3067 6.37812C33.9312 6.37812 32.8496 7.48086 32.8496 8.84956V14.3804C32.8496 15.7491 33.9312 16.8519 35.3067 16.8519H44.7027C46.0781 16.8519 47.1597 15.7491 47.1597 14.3804V8.84956C47.1597 7.48086 46.0781 6.37812 44.7027 6.37812H35.3067ZM38.0595 8.12949C38.1749 8.13322 38.2881 8.16994 38.3859 8.23544L42.6927 11.0009C42.9191 11.1419 43.0022 11.403 43.0022 11.615C43.0022 11.8269 42.9187 12.0878 42.6924 12.2288L38.3862 14.9946C38.1806 15.132 37.9132 15.1341 37.706 15.002L37.7043 15.0009C37.4997 14.8683 37.3782 14.6208 37.3845 14.3727V8.84956C37.3797 8.49772 37.6466 8.14891 38.0086 8.13007L38.0595 8.12949ZM38.7038 13.1249L41.0623 11.6144L38.7038 10.0996V13.1249ZM42.675 11.8255C42.6603 11.8574 42.6421 11.887 42.6201 11.913L42.6503 11.8717C42.6595 11.8571 42.6677 11.8414 42.675 11.8255Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14.5V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 507 B

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14.5V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 507 B

View File

@@ -1,51 +0,0 @@
/* Pre-Vue splash loader — colors set by inline script */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}

View File

@@ -78,14 +78,6 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
vi.mock('@/scripts/app', () => ({
app: {
menu: {
element: document.createElement('div')
}
}
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -139,18 +131,6 @@ function createWrapper({
})
}
function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
'[data-testid="legacy-topbar-container"]'
).element
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
return legacyContainer
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
@@ -535,69 +515,4 @@ describe('TopMenuSection', () => {
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const rafCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})
const wrapper = createWrapper({ pinia, attachTo: document.body })
try {
await nextTick()
const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(wrapper)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
const initialCallbacks = [...rafCallbacks]
rafCallbacks.length = 0
initialCallbacks.forEach((callback) => callback(0))
await nextTick()
}
querySpy.mockClear()
querySpy.mockReturnValue(document.createElement('div'))
for (let index = 0; index < 3; index++) {
const outer = document.createElement('div')
const inner = document.createElement('div')
inner.textContent = `legacy-${index}`
outer.appendChild(inner)
legacyContainer.appendChild(outer)
}
await vi.waitFor(() => {
expect(rafCallbacks.length).toBeGreaterThan(0)
})
expect(querySpy).not.toHaveBeenCalled()
const callbacks = [...rafCallbacks]
rafCallbacks.length = 0
callbacks.forEach((callback) => callback(0))
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer.classes()).toContain('px-2')
} finally {
wrapper.unmount()
vi.unstubAllGlobals()
}
})
})

View File

@@ -39,7 +39,6 @@
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
@@ -287,7 +286,6 @@ onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'
legacyCommandsContainerRef.value.appendChild(app.menu.element)
checkLegacyContent()
}
})

View File

@@ -1,7 +1,6 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
@@ -43,9 +42,6 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}
@@ -58,7 +54,6 @@ export function useAppSetDefaultView() {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {

View File

@@ -54,12 +54,11 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<i class="size-5" :class="item.icon" />
{{ item.label }}
<div
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
itemProp
)
)

View File

@@ -33,20 +33,19 @@
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
@dragstart.prevent
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
@@ -66,7 +65,7 @@
</template>
<script setup lang="ts">
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -74,8 +73,8 @@ import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
min,
max,
step = 1,
disabled = false,
hideButtons = false,
@@ -97,7 +96,6 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const textEdit = ref(false)
onClickOutside(container, () => {
@@ -105,11 +103,21 @@ onClickOutside(container, () => {
})
function clamp(value: number): number {
return Math.min(max, Math.max(min, value))
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
@@ -127,27 +135,41 @@ function handleBlur(e: Event) {
textEdit.value = false
}
let dragDelta = 0
function handlePointerUp() {
if (isSwiping.value) return
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function handlePointerUp() {
if (!dragging.value) return
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: Omit<ComponentExposed<C>, 'focus'>
}
const meta: GenericMeta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text'
},
placeholder: {
control: 'text'
},
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
},
'onUpdate:modelValue': { action: 'update:modelValue' },
onSearch: { action: 'search' }
},
args: {
modelValue: '',
placeholder: 'Search...',
showBorder: false,
size: 'md'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchBox },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchBox v-bind="args" v-model="searchText" />
</div>
`
})
}
export const WithBorder: Story = {
...Default,
args: {
showBorder: true
}
}
export const NoBorder: Story = {
...Default,
args: {
showBorder: false
}
}
export const MediumSize: Story = {
...Default,
args: {
size: 'md',
showBorder: false
}
}
export const LargeSize: Story = {
...Default,
args: {
size: 'lg',
showBorder: false
}
}
export const LargeSizeWithBorder: Story = {
...Default,
args: {
size: 'lg',
showBorder: true
}
}

View File

@@ -1,193 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWidgets: {
sort: {
searchPlaceholder: 'Search...'
}
}
}
}
})
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createWrapper = (props = {}) => {
return mount(SearchBox, {
props: {
modelValue: '',
...props
},
global: {
plugins: [i18n]
}
})
}
describe('debounced search functionality', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type search query
await input.setValue('test')
// Model should not update immediately
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 299ms (just before debounce delay)
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 1ms more (reaching 300ms)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
// Model should now be updated
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type first character
await input.setValue('t')
vi.advanceTimersByTime(200)
await nextTick()
// Type second character (should reset timer)
await input.setValue('te')
vi.advanceTimersByTime(200)
await nextTick()
// Type third character (should reset timer again)
await input.setValue('tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
// Should not have emitted yet (only 200ms passed since last keystroke)
expect(wrapper.emitted('search')).toBeFalsy()
// Advance final 100ms to reach 300ms
await vi.advanceTimersByTimeAsync(100)
await nextTick()
// Should now emit with final value
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
})
it('should only emit final value after rapid typing', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Simulate rapid typing
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
}
await nextTick()
// Should not have emitted yet
expect(wrapper.emitted('search')).toBeFalsy()
// Complete the debounce delay
await vi.advanceTimersByTimeAsync(350)
await nextTick()
// Should emit only once with final value
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
})
describe('bidirectional model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = createWrapper({ modelValue: 'initial' })
const input = wrapper.find('input')
expect(input.element.value).toBe('initial')
// Update model externally
await wrapper.setProps({ modelValue: 'external update' })
await nextTick()
// Internal state should sync
expect(input.element.value).toBe('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = createWrapper({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
expect(input.attributes('aria-label')).toBe('Custom search...')
})
it('should use default placeholder when not provided', () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
expect(input.attributes('aria-label')).toBe('Search...')
})
})
describe('autofocus', () => {
it('should focus input when autofocus is true', async () => {
const wrapper = createWrapper({ autofocus: true })
await nextTick()
const input = wrapper.find('input')
const inputElement = input.element as HTMLInputElement
// Note: In JSDOM, focus() doesn't actually set document.activeElement
// We can only verify that the focus method exists and doesn't throw
expect(inputElement.focus).toBeDefined()
})
it('should not autofocus when autofocus is false', () => {
const wrapper = createWrapper({ autofocus: false })
const input = wrapper.find('input')
expect(document.activeElement).not.toBe(input.element)
})
})
describe('click to focus', () => {
it('should focus input when wrapper is clicked', async () => {
const wrapper = createWrapper()
const wrapperDiv = wrapper.find('[class*="flex"]')
await wrapperDiv.trigger('click')
await nextTick()
// Input should receive focus
const input = wrapper.find('input').element as HTMLInputElement
expect(input.focus).toBeDefined()
})
})
})
})

View File

@@ -1,139 +0,0 @@
<template>
<div
:class="
cn(
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
customClass,
wrapperStyle
)
"
>
<InputText
ref="inputRef"
v-model="modelValue"
:placeholder
:autofocus
unstyled
:class="
cn(
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
isLarge ? 'pl-11' : 'pl-8'
)
"
:aria-label="placeholder"
/>
<Button
v-if="filterIcon"
size="icon"
variant="textonly"
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
@click="$emit('showFilter', $event)"
>
<i :class="filterIcon" />
</Button>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
variant="textonly"
size="icon"
@click="modelValue = ''"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { cn } from '@comfyorg/tailwind-utils'
import { watchDebounced } from '@vueuse/core'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false,
showBorder = false,
size = 'md',
class: customClass
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const isLarge = computed(() => size === 'lg')
const emit = defineEmits<{
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const modelValue = defineModel<string>({ required: true })
const inputRef = ref()
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
watchDebounced(
modelValue,
(value: string) => {
emit('search', value, filters)
},
{ debounce: debounceTime }
)
const wrapperStyle = computed(() => {
if (showBorder) {
return cn(
'box-border rounded-sm border border-solid border-border-default p-2',
isLarge.value ? 'h-10' : 'h-8'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn('rounded-lg', sizeClasses)
})
</script>
<style scoped>
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}
</style>

View File

@@ -1,90 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchBoxV2 from './SearchBoxV2.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn(() => vi.fn())
}))
describe('SearchBoxV2', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
function mountComponent(props = {}) {
return mount(SearchBoxV2, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div><slot /></div>'
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
it('uses i18n placeholder when no placeholder prop provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
it('uses custom placeholder when provided', () => {
const wrapper = mountComponent({
placeholder: 'Custom placeholder'
})
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom placeholder')
})
it('shows search icon when search term is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
})
it('shows clear button when search term is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button').exists()).toBe(true)
})
it('clears search term when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('applies large size classes when size is lg', () => {
const wrapper = mountComponent({ size: 'lg' })
expect(wrapper.html()).toContain('size-5')
})
it('applies medium size classes when size is md', () => {
const wrapper = mountComponent({ size: 'md' })
expect(wrapper.html()).toContain('size-4')
})
})

View File

@@ -1,117 +0,0 @@
<template>
<div class="flex flex-auto flex-col gap-2">
<ComboboxRoot :ignore-filter="true" :open="false">
<ComboboxAnchor
:class="
cn(
'relative flex w-full cursor-text items-center',
'rounded-lg bg-comfy-input text-comfy-input-foreground',
showBorder &&
'box-border border border-solid border-border-default',
sizeClasses,
className
)
"
>
<i
v-if="!searchTerm"
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
/>
<Button
v-else
class="absolute left-2"
variant="textonly"
size="icon"
:aria-label="$t('g.clear')"
@click="clearSearch"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ComboboxInput
ref="inputRef"
v-model="searchTerm"
:class="
cn(
'size-full border-none bg-transparent text-sm outline-none',
inputPadding
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
/>
</ComboboxAnchor>
</ComboboxRoot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { watchDebounced } from '@vueuse/core'
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
debounceTime = 300,
autofocus = false,
showBorder = false,
size = 'md',
class: className
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
search: [value: string]
}>()
const searchTerm = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
const isLarge = computed(() => size === 'lg')
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
const sizeClasses = computed(() => {
if (showBorder) {
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
}
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
})
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
function clearSearch() {
searchTerm.value = ''
}
watchDebounced(
searchTerm,
(value: string) => {
emit('search', value)
},
{ debounce: debounceTime }
)
</script>

View File

@@ -1,15 +1,9 @@
<template>
<div class="system-stats">
<div class="mb-6">
<div class="mb-4 flex items-center gap-2">
<h2 class="text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
</div>
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
@@ -52,21 +46,15 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
@@ -79,7 +67,6 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
@@ -107,7 +94,6 @@ const cloudColumns: ColumnDef[] = [
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
@@ -122,10 +108,8 @@ function isOutdated(column: ColumnDef): boolean {
return !!installed && !!required && installed !== required
}
function getDisplayValue(column: ColumnDef) {
const value = column.getValue
? column.getValue()
: systemInfo.value[column.field]
const getDisplayValue = (column: ColumnDef) => {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}
@@ -134,33 +118,4 @@ function getDisplayValue(column: ColumnDef) {
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 min-w-0 p-0 pb-2"
class="m-0 p-0 pb-6"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -99,7 +99,7 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -74,22 +74,17 @@
</template>
<script setup lang="ts">
import { computed, toRef, useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurveInterpolation, CurvePoint } from './types'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',
histogram,
interpolation = 'monotone_cubic'
} = defineProps<{
const { curveColor = 'white', histogram } = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
interpolation?: CurveInterpolation
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -100,8 +95,7 @@ const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue,
interpolation: toRef(() => interpolation)
modelValue
})
const histogramPath = computed(() =>

View File

@@ -1,60 +1,16 @@
<template>
<div class="flex flex-col gap-1">
<Select
:model-value="modelValue.interpolation"
@update:model-value="onInterpolationChange"
>
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="interp in CURVE_INTERPOLATIONS"
:key="interp"
:value="interp"
>
{{ $t(`curveWidget.${interp}`) }}
</SelectItem>
</SelectContent>
</Select>
<CurveEditor
:model-value="modelValue.points"
:interpolation="modelValue.interpolation"
@update:model-value="onPointsChange"
/>
</div>
<CurveEditor v-model="modelValue" />
</template>
<script setup lang="ts">
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
const modelValue = defineModel<CurveData>({
default: () => ({
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
})
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
})
function onPointsChange(points: CurvePoint[]) {
modelValue.value = { ...modelValue.value, points }
}
function onInterpolationChange(value: unknown) {
if (typeof value !== 'string') return
modelValue.value = {
...modelValue.value,
interpolation: value as CurveInterpolation
}
}
</script>

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import {
createLinearInterpolator,
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
@@ -74,64 +73,6 @@ describe('createMonotoneInterpolator', () => {
})
})
describe('createLinearInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createLinearInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createLinearInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.8, 10)
expect(interpolate(1)).toBe(1)
})
it('linearly interpolates between points', () => {
const points: CurvePoint[] = [
[0, 0],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0.25)).toBeCloseTo(0.25, 10)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(0.75)).toBeCloseTo(0.75, 10)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(1)).toBe(1)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([

View File

@@ -1,50 +1,4 @@
import type { CurveInterpolation, CurvePoint } from './types'
/**
* Piecewise linear interpolation through sorted control points.
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createLinearInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
return ys[lo] + t * (ys[hi] - ys[lo])
}
}
/**
* Factory that dispatches to the correct interpolator based on type.
*/
export function createInterpolator(
points: CurvePoint[],
interpolation: CurveInterpolation
): (x: number) => number {
return interpolation === 'linear'
? createLinearInterpolator(points)
: createMonotoneInterpolator(points)
}
import type { CurvePoint } from './types'
/**
* Monotone cubic Hermite interpolation.
@@ -152,12 +106,9 @@ export function histogramToPath(histogram: Uint32Array): string {
return parts.join(' ')
}
export function curvesToLUT(
points: CurvePoint[],
interpolation: CurveInterpolation = 'monotone_cubic'
): Uint8Array {
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createInterpolator(points, interpolation)
const interpolate = createMonotoneInterpolator(points)
for (let i = 0; i < 256; i++) {
const x = i / 255

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