Compare commits

...

48 Commits

Author SHA1 Message Date
jaeone94
5b2ddf525a test: cover missing node pack error interactions 2026-06-10 00:36:55 +09:00
jaeone94
bc98f71c2d refactor: simplify missing node pack errors 2026-06-09 22:13:05 +09:00
jaeone94
1b90696459 Simplify missing media error presentation (#12705)
## Summary

This is the second PR in the planned Workflow Overview error-tab
modernization stack. It follows the merged execution-error slice in
#12683 and focuses only on Missing Media / Missing Inputs.

The intent is to simplify missing-media errors from
media-type/file-resolution cards into compact node-level guidance.
Missing media is now presented as a flat list of affected node inputs,
matching the new catalog-driven item-row direction introduced by the
first PR while intentionally avoiding the larger missing-node,
swap-node, and missing-model redesigns.

This PR also removes the upload/library selection workflow from the
error tab. Missing media rows now help the user locate the affected
node/input directly, rather than attempting to resolve the missing file
inside the error card.

## Changes

- **What**: Simplifies the Missing Inputs card into a flat list of
affected node/widget rows.
- **What**: Displays each missing media row with a catalog-resolved item
label in the form `{nodeName} - {inputName}`.
- **What**: Makes both the row label and right-aligned locate icon
navigate to the affected node on canvas.
- **What**: Adds item-specific accessible labels for locate icon buttons
and an explicit focus-visible state for clickable row labels.
- **What**: Uses the existing `list-scale` transition pattern so missing
media rows animate consistently when they enter or leave.
- **What**: Removes the old image/video/audio sub-sections from the
missing-media card.
- **What**: Removes missing-media
upload/dropzone/library-selection/status/confirm/cancel UI, because
resolution is no longer handled inside this card.
- **What**: Removes stale E2E selectors and tests tied to the deleted
upload/library controls.
- **What**: Removes stale English missing-media locale keys for the
deleted controls; translated locale files are intentionally left to the
localization sync process.
- **What**: Removes stale missing-media store interaction state
(`expandState`, `uploadState`, `pendingSelection`) and the now-unused
name-based removal API that only supported the deleted upload/library
flow.
- **What**: Keeps the remaining widget/node/prefix candidate-removal
APIs used by the missing-media lifecycle.
- **What**: Carries `nodeType` through missing-media referencing nodes
so fallback labels remain correct when the same missing filename is
referenced by multiple node types.
- **Breaking**: None expected. This changes the missing-media error-tab
UX, but does not change the workflow schema or backend APIs.
- **Dependencies**: None.

## Planned stack

This PR is part of the same staged redesign plan described in #12683.
The goal is to keep each error category reviewable and stable before
moving to the next slice.

1. Execution errors, merged in #12683: validation, runtime, and prompt
error grouping/presentation.
2. Missing media, this PR: simplify image/video/audio missing-media
cards into node-level catalog item rows with locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

This PR intentionally does not change missing node packs, swap nodes,
missing models, runtime errors, validation errors, or prompt errors.

## Review Focus

- Missing media rows should now be node-level rows, not file-level rows
and not image/video/audio sections.
- If the same missing media filename is referenced by multiple nodes,
each affected node/input should render as its own row.
- Clicking either the row label or locate icon should focus the
corresponding affected node.
- Removed upload/library controls are intentional and should not be
restored in this PR.
- The missing-media store cleanup should not remove any state or API
still used by the remaining missing-media lifecycle.
- Only `rightSidePanel.missingMedia.missingMediaTitle` should remain in
the English locale for the old missing-media UI namespace; new row copy
lives in the error catalog.
- This PR should stay scoped to Missing Media / Missing Inputs and
should not include the larger missing model/node/swap redesign work.

## Screenshots (if applicable)

This PR
<img width="483" height="339" alt="스크린샷 2026-06-08 오후 8 26 43"
src="https://github.com/user-attachments/assets/e029411e-b1db-41cd-aa6e-2bd5d86762f6"
/>

Before (Main)
<img width="484" height="802" alt="스크린샷 2026-06-08 오후 8 32 19"
src="https://github.com/user-attachments/assets/c183c18d-babe-4f4e-b4e0-706ce41298de"
/>

## Validation

- `pnpm format`
- `pnpm test:unit src/platform/missingMedia/missingMediaStore.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts`
- `pnpm typecheck`
- `pnpm knip --cache`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts
--project=chromium`
- Pre-push hook: `pnpm knip --cache`

## Review notes

Claude review was run after the main implementation and again after
cleanup. No blocker or major issues remained. The only final minor
finding was stale inline i18n fixture data in `TabErrors.test.ts`, which
was removed in the final commit.
2026-06-09 06:52:35 +00:00
Comfy Org PR Bot
e340b5b127 1.46.11 (#12722)
Patch version increment to 1.46.11

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-09 05:56:12 +00:00
Deep Mehta
68238a5742 fix(website): show Windows + Mac fallback on /download when UA detection fails (#12727)
## What
The /download hero now surfaces both Windows AND macOS download buttons
when the post-mount UA sniff comes back empty (Linux desktop, mobile,
privacy-stripped UAs). Today those users see only the GitHub-install
button — no actual download link.

## Why
Field complaint: \"I can't find a download link at all on this page.\"
Reproduced on Chrome with a Linux UA — \`useDownloadUrl\` returns
\`platform: null\`, and \`<DownloadLocalButton v-show=\"platform\">\`
collapses to \`display: none\`. The user only sees \"INSTALL FROM
GITHUB\" next to it, with no obvious way to grab the Windows or Mac dmg.

Showing both is the better fallback — they're the two artifacts we
actually ship today (\`downloadUrls.windows\`, \`downloadUrls.macArm\`),
the UA path already encodes them, and the buttons exist as fragment
siblings so they slot into the parent's flex container next to the
existing GitHub button without extra layout wrapping.

## How
- \`useDownloadUrl\` now exposes a \`detected\` ref that flips to
\`true\` post-mount regardless of whether UA matched. Lets the component
distinguish three states:
- **\`!detected\`** → pre-hydration, render nothing (avoids SSR flicker)
  - **\`detected && platform\`** → single matched CTA, current behaviour
  - **\`detected && !platform\`** → NEW: render both Windows AND Mac
- \`DownloadLocalButton\` adds the fallback branch as a \`<template
v-else-if>\` fragment so the two buttons slot directly into the parent's
existing \`flex-col lg:flex-row\` layout next to the GitHub button. Each
carries its OS icon and an \`aria-label\` combining the existing
\"DOWNLOAD DESKTOP\" copy with the OS name so screen readers get equal
context to the detected-OS path.
- \`downloadUrls\` is now re-exported from the composable so the
fallback path uses the same source of truth instead of duplicating the
URLs.

## Tests
- [x] New \`@smoke\` e2e test (\`HeroSection falls back to both Windows
+ Mac when UA is unrecognized\`) — uses Linux UA, asserts both fallback
buttons appear with the right hrefs / target=\"_blank\", and confirms
the auto-detected single CTA is NOT also present
- [x] Existing Windows-UA test (\`HeroSection has download and GitHub
buttons\`) still passes — single CTA branch unchanged
- [ ] Manual: load /download on Linux Chrome → both buttons visible
- [ ] Manual: load /download on Windows → still single \"DOWNLOAD
DESKTOP\" button (no regression)
- [ ] Manual: load /download on macOS → still single \"DOWNLOAD
DESKTOP\" button with Apple icon
- [ ] Manual: load /download on mobile (which detection treats as null)
→ both fallback buttons visible
2026-06-09 05:50:48 +00:00
Comfy Org PR Bot
183a75a218 [chore] Update Ingest API types from cloud@ca12913 (#12726)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: ca12913
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 04:39:50 +00:00
Dante
507e667795 feat: register unified_cloud_auth feature flag (FE-950) - step 1 (#12702)
## Summary
https://us.posthog.com/project/204330/feature_flags/706651 

Register the `unified_cloud_auth` server feature flag (default **off**)
so the upcoming single Cloud-JWT auth provider (FE-950/FE-951) can be
gated behind it. Pure plumbing — no behavior change.

## Changes

- **What**: Adds `ServerFeatureFlag.UNIFIED_CLOUD_AUTH`, the
`unifiedCloudAuthEnabled` getter in `useFeatureFlags` (via
`resolveFlag`, precedence: dev override > `remoteConfig` > server
feature), and the `unified_cloud_auth?: boolean` field on the
`RemoteConfig` type. Unit tests cover the server-feature wiring and the
dev-override toggle.
- This is PR 1 of 3 for FE-950. Nothing reads the flag yet; it resolves
`false` by default, so this is inert in every environment until a later
PR wires consumers.

## Review Focus

- Mirrors the existing `resolveFlag`-based flags (e.g.
`userSecretsEnabled`, `workflowSharingEnabled`) — deliberately the plain
shape, **not** the cloud-gated `teamWorkspacesEnabled` shape, since the
flag is only read from paths already `isCloud`-gated.
- Local toggle for development:
`localStorage.setItem('ff:unified_cloud_auth', 'true')` (DEV-only dev
override).
- Production delivery of the flag value (and staged % rollout) is
backend work — Comfy-Org/cloud#4139 (BE-1258), already adds
`unified_cloud_auth` to `/api/features`. This FE PR is independent of
it; with no server value the flag stays `false`.

Linear: FE-950

## Screenshots (if applicable)

N/A — no user-facing change (flag registration only).
2026-06-09 02:15:33 +00:00
Comfy Org PR Bot
d617ab1c95 [chore] Update Ingest API types from cloud@1dc7e7e (#12227)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 1dc7e7e
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 01:29:43 +00:00
Dante
b93338cac7 fix(keybindings): progressively truncate keybinding column at narrow widths (#12181)
## Summary
- Progressive container-query truncation for the **Keybinding** column
in the Settings → Keybinding panel so the **actions column stops getting
clipped** at narrow widths.
- Tiers (all driven by `@container/keybindings` on the cell content — no
JS resize listeners):
  - `>= 16rem`: `Ctrl S , Ctrl Shift S + 1 more` (2 combos + `N-2 more`)
  - `>= 12rem`: `Ctrl S + 2 more` (1 combo + `N-1 more`)
  - `>= 8rem`: `Ctrl S + 2` (1 combo + compact `+N`)
  - smallest: first combo only
- Actions column pinned with `whitespace-nowrap` so it always stays
fully visible — the keybindings column compresses first, not the icons.
- New `KeybindingList.vue` extracted from `KeybindingPanel.vue`; the
prior inline rendering was inlined twice (column body + we'd need it
elsewhere if expansion logic changes).
- Adds `g.nMoreKeybindingsCompact` and `g.keybindingListAriaLabel` i18n
keys (en only; other locales fall back to en).

## Before / after

### before


https://github.com/user-attachments/assets/227cdd6a-fd5d-41ba-8af2-2a49e2d9cf15

### after
<img width="936" height="812" alt="Screenshot 2026-05-13 at 11 05 12 AM"
src="https://github.com/user-attachments/assets/f750efd8-7d14-4a46-a92d-fa67e57c7909"
/>








<!-- Drag the PNGs from ~/Desktop/fe-523-screenshots/ into this section.
Recommended order: wide → 16rem → 12rem → 8rem, before then after at
each tier.
Captured by temp/scripts/capture-fe523-screenshots.ts at 4 dialog
widths. -->

_screenshots pending — drag/drop from `~/Desktop/fe-523-screenshots/`_

The narrow tier shows the original FE-523 bug clearly: on `main` the
Source and Actions columns are clipped without a scrollbar; with this PR
the keybinding column compresses first, keeping at least the edit icon
visible. Some action icons still get clipped at the absolute narrowest
tier — full sticky-actions behavior is a follow-up.

## Why draft
Manual visual verification of the four container-query tiers on a real
backend still needs to happen — the snapshot script runs against a
stubbed-out backend, so the dataset is sparse (the multi-combo rows
aren't injected). Moving out of draft after that pass.

## References
- Fixes
[FE-523](https://linear.app/comfyorg/issue/FE-523/update-keybinding-panel-responsiveness)
- Figma proposal:
https://www.figma.com/design/ZNLRans6JLM3hvCz5LlCQA/Keyboard-Shortcut?node-id=625-17286
- Slack thread:
https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1776818615078629?thread_ts=1776808927.654249&cid=C075ANWQ8KS

## Test plan
- [x] `pnpm test:unit` — new `KeybindingList.test.ts` covers all 4 tiers
(0/1/2/3+ keybindings, count math `N-2` vs `N-1`, container class
present, aria-label assembled).
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] Snapshot diff at 4 widths (see Before / after section above once
images are attached).
- [ ] Manual on a real backend: command with 1 / 2 / 3 / 5 keybindings
each renders the correct `+N more` / `+N` value at each tier.
- [ ] Manual: confirm whether residual actions-column clipping at the
absolute narrowest tier warrants a follow-up.
2026-06-08 23:26:13 +00:00
Deep Mehta
fa2d187c83 feat(website): add Desktop privacy policy at /privacy/desktop (#12714)
## Summary

Publishes the Comfy Desktop privacy policy (currently bundled in-app
only) as a public page at \`/privacy/desktop\`, so users can read what
they consented to without re-opening the app and so the document is
verifiable / link-shareable. Also adds a single-sentence link from the
existing \`/privacy-policy\` page so people who land on the org-wide
policy can find the surface-specific one.

## Changes

- **What**: New marketing-site page at \`/privacy/desktop\` (en) and
\`/zh-CN/privacy/desktop\` (zh), backed by ~50 new \`desktop_privacy.*\`
i18n keys mirroring the in-app document. One-sentence addition to
\`privacy.intro.block.3\` linking to the new page.
- **Breaking**: None.
- **Dependencies**: None.

## Why now

Recent Discord audit by a user (cited Civitai as precedent) identified
six legitimate gaps between our website privacy policy and what GDPR /
UK GDPR Art 13 requires (named processors, lawful basis mapping,
retention specifics, international transfer mechanism, etc.). The
**in-app Desktop policy already addresses all of them**, but it isn't
publicly visible. This PR makes it visible at a URL.

A separate, counsel-reviewed PR will be needed to bring the org-wide
\`/privacy-policy\` page up to the same standard — that's deliberately
not in scope here.

## Content the new page covers

Mirrors the Desktop in-app document (effective 2026-05-19):

- Named processors: PostHog, Datadog, ToDesktop, Comfy Org analytics
warehouse
- Article 6 mapping: 6(1)(a) consent for analytics + crash reports;
6(1)(f) legitimate interests for update server
- Retention: 24 months analytics / 15 days crash / 36 months aggregate /
90 days update logs / removed on uninstall
- International transfers: Standard Contractual Clauses where required
- Data subject rights + \`privacy@comfy.org\` as contact
- Right to lodge complaint with ICO / EU supervisory authority / CPPA

## Review Focus

1. **i18n zh-CN placeholders.** Following the existing convention in
this file (see e.g. \`affiliate-landing.footerCta\`), zh-CN values use
the English text as placeholders. A follow-up PR will land Chinese
translations. Flag if there's a different preferred convention.
2. **Source-of-truth coupling.** This page is a publish of the bundled
in-app policy at \`src/renderer/src/lib/legalDocs.ts\`
(\`PRIVACY_POLICY\` constant) in the \`Comfy-Desktop\` repo. There's no
automated sync — the two need to stay aligned manually when the in-app
policy is revised. Worth a quarterly review reminder.
3. **\`privacy@comfy.org\` mailbox.** The policy directs all data
subject rights requests there. Confirm this inbox exists and is
monitored before merge.
4. **No \`noindex\` on the new page.** Unlike the existing
\`/privacy-policy\` page, this one is intentionally indexable — the
point is that it should be discoverable.

## Test plan

- [x] \`pnpm typecheck\` clean (0 errors, 0 warnings)
- [x] \`pnpm build\` succeeds; both \`/privacy/desktop/index.html\` and
\`/zh-CN/privacy/desktop/index.html\` are emitted
- [x] All 385 site pages still build
- [ ] After preview deploy: verify the link from \`/privacy-policy\` to
\`/privacy/desktop\` renders correctly, and the page table-of-contents
nav works (\`ContentSection\` infers sections from i18n keys)

## Companion items (not in this PR)

- Update \`comfy.org/privacy-policy\` (the org-wide policy) with named
processors, Art 6 mapping, retention specifics, and SCC language —
requires privacy counsel review
- Confirm Comfy Org Inc ICO registration status
- One-page determination from privacy counsel on whether Art 37 requires
a formal DPO (pseudonymous product telemetry is likely below threshold,
but the question deserves a documented answer)
2026-06-08 23:22:43 +00:00
bertfy
6f22ca11c3 feat(website): rename "Local" CTAs/branding to "Desktop" (#12689)
*PR Created by the Glary-Bot Agent*

---

## Summary

Renames the user-facing "Local" copy to "Desktop" across the comfy.org
marketing site (English + Chinese). Covers three surfaces requested in
sequence by the user:

1. "Download Local" CTA → "Download Desktop" (top nav, mobile menu,
homepage Get-started section, download page hero button).
2. "Comfy Local" nav-dropdown / footer label → "Comfy Desktop".
3. The yellow `LOCAL` badge in the `/download` hero → `DESKTOP`.

## Changes

- **i18n** (`apps/website/src/i18n/translations.ts`)
- `getStarted.step1.downloadLocal`, `download.hero.downloadLocal`,
`nav.downloadLocal`: en `Local` → `Desktop`, zh-CN `本地版` → `桌面版`.
- `nav.comfyLocal`: en `Comfy Local` → `Comfy Desktop`, zh-CN `Comfy
本地版` → `Comfy 桌面版`.
- **Nav animated CTA** (`SiteNav.vue`): hardcoded `core: 'LOCAL'` →
`core: 'DESKTOP'` (the visible text in the desktop nav, which is
separate from the i18n aria-label).
- **Download hero badge** (`product/local/HeroSection.vue`): passes
`text="DESKTOP"` to `<ProductHeroBadge />`, matching how the
Cloud/API/Enterprise heroes already specify their own text.
- **Shared badge default** (`common/ProductHeroBadge.vue`): default
`text` changed from `'LOCAL'` to `'DESKTOP'` so future callers do not
inherit retired branding.
- **E2E tests** updated to the new strings (navigation, homepage,
download specs).

## Out of scope (intentionally unchanged)

- The internal product-family slug `local` (Vue component file names
like `DownloadLocalButton.vue`, directory `components/product/local/`,
translation key `comfyLocal`, route `/download`). Renaming these is
internal restructuring that would balloon the diff with no user-visible
benefit and was not requested.
- The homepage products-card strings `products.local.title` ("Comfy
Local") and `products.local.cta` ("SEE LOCAL FEATURES"). These also live
on the homepage and link to `/download`; the user has been driving this
rename surface-by-surface and has not flagged this card yet. Happy to
extend in a follow-up if desired.
- Other "local" occurrences (e.g. "本地运行" in the zh-CN download FAQ, "Run
on your hardware", etc.) — those describe the deployment model, not the
product brand.

## Verification

- `pnpm --filter @comfyorg/website typecheck`: 0 errors
- `pnpm exec oxlint` on every touched file: 0 errors, 0 warnings
- `pnpm exec oxfmt --check` on every touched file: clean
- Husky `lint-staged` (typecheck + lint + format) ran green on each
commit
- Manual browser verification with Playwright at desktop (1440×900) and
mobile (390×844) viewports on `/`, `/download`, `/zh-CN/`,
`/zh-CN/download`:
  - Top nav CTA reads `DOWNLOAD DESKTOP` (aria-label `下载桌面版` on zh-CN)
  - Mobile menu CTA reads `DOWNLOAD DESKTOP`
  - Homepage Get-started section reads `Download Desktop`
  - Products dropdown shows `Comfy Desktop` as the first item
  - `/download` hero badge reads `Comfy DESKTOP` (en + zh-CN)
- DOM `innerText` contains 0 occurrences of `LOCAL`/`下载本地版`/`Comfy 本地版`

## Screenshots

Attached: desktop nav, homepage Get-started section, download hero badge
(en + zh-CN), mobile menu.

## Screenshots

![Desktop nav showing DOWNLOAD DESKTOP
CTA](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875107228-7f86d778-c1fd-4eff-89eb-857c64760bf2.png)

![Homepage Get started section showing Download Desktop
link](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875107536-4e7a8d15-57d7-4a97-b3dc-414f86c09e71.png)

![Download page hero with Comfy DESKTOP yellow
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875107833-b80a479e-18a5-45c7-92b9-a41dcd82741f.png)

![zh-CN download page hero with Comfy DESKTOP
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875108144-028f680d-f949-4960-9424-8c8096023222.png)

![Mobile menu showing DOWNLOAD DESKTOP
CTA](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/8df93ff9df744afaf52af9f712468ec540611a127894815633c8d1b902dcb0cb/pr-images/1780875108481-e4a586ba-1b65-4077-b02c-517252f92255.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-06-08 20:23:11 +00:00
Robin Huang
c17f91095a feat: track search keystrokes across 5 surfaces (#12618)
## Summary

Adds a single `app:search_keystroke` PostHog event capturing the
debounced search query across five surfaces (`node_search_modal`,
`node_sidebar`, `apps_sidebar`, `template_search`, `settings_search`).
Today four of these emit nothing; the fifth (node search modal) fires
the disabled `app:node_search`.

## Changes

- **What**: Single composable `useSearchKeystrokeTracking(surface,
queryRef)` wired into each search input. 500ms debounce, trims and skips
empty queries.
- One event with a `surface` property (not 5 events) — matches the
existing `app:share_flow` precedent and lets PostHog break down across
surfaces uniformly.
- Replaces the disabled `app:node_search` call in `NodeSearchBox.vue`
(V1 modal) and wires the V2 modal at
`searchbox/v2/NodeSearchContent.vue`.

## Review Focus

- Five wire-ups, all single-line additions next to each `searchQuery`
ref.
- New event `app:search_keystroke` lands ready to query in PostHog with
`surface`, `query`, `query_length`.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 17:33:41 +00:00
Comfy Org PR Bot
b67d77f08a 1.46.10 (#12660)
Patch version increment to 1.46.10

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-08 17:31:43 +00:00
AustinMroz
d7f0d75efd Support fast disco on subgraph IO (#12619)
Because subgraph links don't follow the normal link logic, the fast
disconnection circle would not function when dragging input links that
originate from a subgraph IO node. There's some sad duplication of
state, but I think the flow is marginally cleaner now by not requiring
`instanceof` checks.

The test added by this PR also verifies behavior for the node-to-node
case.
2026-06-08 16:07:34 +00:00
jaeone94
74b9f16b62 Refine execution error presentation (#12683)
## Summary

This is the first PR in a planned stack to modernize the Workflow
Overview error tab. It focuses only on execution-style errors:
validation errors, runtime errors, and known prompt errors.

The intent is to establish the catalog-driven presentation model before
touching the larger missing-resource cards. Validation and prompt errors
are known product states, so this PR makes them read more like
structured guidance instead of generic reportable failures. Runtime
errors remain reportable, but their details are reorganized so the error
log is easier to scan and copy.

## What changed

- Groups validation errors by error catalog id instead of node
class/type.
- Adds an `unknown_validation_error` fallback catalog id so validation
grouping can follow one rule without special-case missing catalog ids.
- Shows validation group title and message once, then lists each
affected input as a compact item row.
- Adds per-item validation detail disclosure so detailed validation text
is still available without repeating the group title/message for every
item.
- Keeps locate-node behavior available from validation rows, including
keyboard/ARIA disclosure wiring.
- Removes GitHub, copy, and help actions from validation/prompt errors
because these are known, cataloged errors where the UI copy should guide
the user directly.
- Refines runtime error cards so the error log is visible by default,
has its own header, and keeps copy/report actions inside the log area.
- Removes the special full-panel singleton runtime layout so runtime
errors keep the same fixed card rhythm as the other error groups.
- Keeps runtime errors reportable via Get Help and Find on GitHub,
because these can still represent unexpected execution failures.
- Updates prompt error detail styling to match the darker runtime
error-log treatment.
- Restores display-message semantics for grouped execution messages:
`displayMessage ?? message` is used for user-facing dedupe instead of
raw backend-only messages.
- Adds focused unit coverage for catalog grouping, fallback validation
catalog ids, display-message grouping, runtime detail behavior, and the
updated prompt/validation action model.

## Planned stack

This PR intentionally keeps the first slice narrow. The broader redesign
is planned as a sequence of follow-up PRs rather than one large change:

1. Execution errors, this PR: validation, runtime, and prompt error
grouping/presentation.
2. Missing media: simplify image/video/audio missing-media cards around
catalog item labels and locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

The goal is to review and stabilize each slice before stacking the next
one. This is especially important because later missing-model changes
are much larger and should not obscure the catalog/error-card behavior
introduced here.

## Review focus

- Validation errors should now group by catalog id, not by node class.
- Validation groups intentionally show one message per group, with
individual affected inputs rendered as rows.
- Prompt and validation errors intentionally no longer show
report/copy/help actions.
- Runtime errors intentionally still show report actions, but only
inside the error-log panel.
- Node id badges are intentionally not shown in these execution error
rows; the follow-up missing-resource PRs will handle their own row
treatments separately.
- This PR does not change missing media, missing model, missing node
pack, or swap node cards.

## Screenshots  
### This PR 

Validation error
<img width="457" height="362" alt="스크린샷 2026-06-07 오전 4 26 19"
src="https://github.com/user-attachments/assets/4c35b9f3-57dd-4dae-b44a-6d2fd8547b7c"
/>

Runtime error
<img width="454" height="545" alt="스크린샷 2026-06-07 오전 4 24 24"
src="https://github.com/user-attachments/assets/b7d4482f-b35b-4ed2-90f2-0a62dafa3519"
/>

Prompt / Service error 
<img width="456" height="192" alt="스크린샷 2026-06-07 오전 4 27 58"
src="https://github.com/user-attachments/assets/aeec0978-b47f-40c7-ab71-0a0d18ceb054"
/>


### Old (main)
Validation error
<img width="457" height="853" alt="스크린샷 2026-06-07 오전 4 25 09"
src="https://github.com/user-attachments/assets/185dd573-430d-4041-8b31-a8eb6346f1ff"
/>

Runtime error
<img width="455" height="554" alt="스크린샷 2026-06-07 오전 4 24 58"
src="https://github.com/user-attachments/assets/deb1c09d-ea58-4d6a-9ac6-d2a3a9832fbe"
/>

Prompt / Service error
<img width="455" height="297" alt="스크린샷 2026-06-07 오전 4 28 14"
src="https://github.com/user-attachments/assets/c68eef7c-6525-4a5b-858c-6482fe76ad27"
/>





## Validation

- `pnpm format:check`
- `pnpm test:unit src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `pnpm build`
2026-06-08 06:52:57 +00:00
jaeone94
dbeb9cc10d fix: clear missing model on promoted widget change (#12677)
## Summary

Fixes FE-942 by clearing missing model indicators when promoted subgraph
widgets are changed through the legacy canvas path.

## Changes

- **What**: Resolves promoted widget error-clearing targets in
`useErrorClearingHooks`, including legacy canvas path events from
interior widgets.
- **Dependencies**: None.

## Review Focus

- Promoted widget validation errors clear by resolved interior widget
name, while missing model/media state clears by source widget name.
- Same-named promoted widgets are value-gated so changing one promoted
model widget does not clear unchanged sibling indicators.
- Core promoted widget event emission remains unchanged; the fix is
scoped to the error-clearing hook.

## Validation

- `pnpm test:unit src/composables/graph/useErrorClearingHooks.test.ts`
- `pnpm test:unit src/core/graph/subgraph/promotedWidgetView.test.ts
src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts`
- `pnpm exec oxfmt --check
src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts`
- `pnpm exec oxlint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts --type-aware`
- `pnpm exec eslint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts`
- `pnpm typecheck`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
--project=chromium --grep Subgraph`
- pre-push `knip --cache`

## Screenshots (if applicable)

N/A
2026-06-08 06:49:10 +00:00
Dante
49a05f32ca fix: stop Add Secret dialog rendering behind Settings modal (FE-939) (#12665)
## Summary

The "Add Secret" dialog (Settings → Secrets) opens but renders
**behind** the Settings modal because it never joins the shared dialog
z-index stack. This is the intermittent "dialog doesn't show" report in
FE-939.

## Changes

- **What**: Apply the existing `v-reka-z-index` directive to
`SecretFormDialog`'s `DialogOverlay` and `DialogContent`, mirroring
`GlobalDialog.vue`. This registers the dialog with the shared
`@primeuix` `ZIndex` `'modal'` sequence instead of relying on the static
`z-1700` Tailwind class, which always lost to the Settings modal once
the shared counter climbed past 1700.

## Review Focus

Root cause: `@primeuix` `ZIndex.set` runs with `autoZIndex=true`, so
every registered modal lands above the last in the shared `'modal'`
sequence — "whichever dialog opens last wins." The Settings modal
registers in that sequence (via `GlobalDialog`'s `v-reka-z-index` in the
reka renderer, or as a PrimeVue `p-dialog-mask` in the cloud build).
`SecretFormDialog` is a standalone reka dialog whose overlay/content
carried only the **static** `z-1700` class and never joined the
sequence, so it sat at a fixed `1700`. Whenever the shared counter has
climbed past 1700 (the report's `2102/2103` state, reached through
repeated dialog/section navigation) the Settings modal paints on top —
hence the intermittency. The ~3-line fix lets Add Secret join the same
sequence so it always opens above.

Scope is intentionally limited to `SecretFormDialog`. Two related items
are filed as follow-ups: FE-940 (durable self-registration in the shared
dialog primitives so static `z-1700` can be dropped everywhere) and
FE-941 (`VideoHelpDialog`, same bug class under the primevue-renderer
`UploadModelDialog`).

Linear:
[FE-939](https://linear.app/comfyorg/issue/FE-939/add-secret-dialog-renders-behind-settings-modal)

## Test plan

New `SecretFormDialog.zindex.test.ts` proves the fix red→green: with a
modal already registered at `1700`, the unfixed dialog content exposes
no inline z-index (`0`, fails the ordering assertion) and the fixed
dialog registers strictly above it (passes). It asserts a **relative**
ordering against a real prior registration, not a hardcoded constant, so
it is a behavioral regression guard rather than a change-detector.
(`@cloud` e2e deferred to FE-940/FE-941 follow-up work — the unit test
is the regression guard.)

CI red→green proof (Tests Unit):
- 🔴 test-only commit `f68d28b6` — [run
failed](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27001789061)
(`AssertionError: expected 0 to be greater than 1701`)
- 🟢 fix commit `add1862db1` —
[run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27002203164)

## Screenshots

Reproduced on cloud with the shared PrimeVue `'modal'` counter driven to
the reported climbed state (`z-2102`); the only difference between the
two is whether Add Secret joins the sequence.

| Before (bug) — Add Secret hidden behind Settings | After (fix) — Add
Secret on top |
| --- | --- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c0357dcd-5554-450e-8d4f-5a589a1566f6"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/80b7dd3f-5a73-4bfb-ae38-1eaed115bb8d"
/> |
2026-06-07 04:40:35 +00:00
Dante
f8187cec4c test: e2e for on-node grid thumbnail previews (FE-741) (#12667)
## Summary

Adds an end-to-end (Playwright) test for the on-node image-grid
thumbnail behavior introduced in #12561 (FE-741), kept in its own PR so
#12561 stays scoped to the fix + unit tests.

#12561 makes small on-node grid cells request a lightweight thumbnail
URL (`preview=webp;75`, server-resized via `res` on cloud) instead of
downloading the full-resolution `/api/view` image. That PR covers the
helper and component with unit tests. This PR adds the missing
**integration** coverage: it drives a real `Preview Image` node in the
browser, injects a multi-image grid over the websocket, and asserts the
rendered grid `<img>` elements request thumbnails.

It exercises the full path the unit tests can't:

`executed` WS output → `nodeOutputStore.buildImageUrls` →
`getGridThumbnailUrl` → rendered grid `<img>` `src`

## What it checks

- A 4-image `Preview Image` grid renders 4 cells (`viewMode ===
'grid'`).
- Every grid cell `<img>` `src` carries the compact thumbnail format
(`preview=webp;75`; the `;` may be percent-encoded as `%3B`).
- Each `src` still points at the real `/api/view` URL for that output
(`filename=grid-<n>.png`), confirming it's the thumbnailed view URL, not
a placeholder/blob.

Lives next to the existing batch-preview test in
`browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts`,
reusing the `ExecutionHelper` + `webSocketFixture` injection pattern.

## Notes

- **Stacked on #12561** (`jaewon/fe-741-onnode-grid-thumbnail-preview`).
Without that fix the grid cells use the full-res URL and this test is
red — i.e. it's a true regression guard. Retarget to `main` once #12561
merges.
- Gallery/full-view URLs staying at full resolution is already covered
by the unit tests in #12561 (`currentImageUrl` is left untransformed);
this test deliberately scopes to the grid path to avoid depending on
injected previews actually resolving on the backend.

## Test Plan

- [x] `pnpm typecheck:browser` clean
- [x] ESLint + oxlint clean
- [ ] e2e CI (`ci-tests-e2e`) green on this PR
2026-06-06 13:10:44 +00:00
Terry Jia
7e61358724 FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache (#12627)
## Summary
The scene widget's serializeValue uploaded a fresh temp image on every
queue, so the `image / mask / normal` filenames in the prompt JSON were
new each run. The backend cache key (which hashes those input strings)
never matched, forcing Load3D and every downstream node to re-execute
even when the user changed nothing.

Track a session-scoped dirty flag and last-output cache in module-level
WeakMaps keyed by LGraphNode. serializeValue returns the cached output
when nothing has changed; user actions that mutate the visible scene
(scene/model/camera/light config, animation, recording, gizmo, camera
orbit) mark dirty through useLoad3d watchers and event handlers. The
model_file / width / height widget callbacks invalidate via a new
optional onSceneInvalidated hook plumbed through Load3DConfiguration, so
the captured screenshot stays consistent with the inputs the backend
sees.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/5ee5f79f-dd38-401e-babe-4d6ea156e56d

After

https://github.com/user-attachments/assets/5e00beb4-937c-4c66-abb2-e455f5301de6
2026-06-06 11:58:52 +00:00
Alexander Brown
ff9e6415b5 fix(nodes-2): apply Textarea widget font-size setting in Vue Nodes 2.0 (#12386)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Settings → Appearance → Node Widget → Textarea widget font size`
(`Comfy.TextareaWidget.FontSize`) was wired through the legacy LiteGraph
textarea only. The Vue Nodes 2.0 `WidgetTextarea.vue` hardcoded Tailwind
`text-xs`, so once Vue nodes were enabled the slider had no effect.

`GraphView.vue` already writes the setting value to
`--comfy-textarea-font-size` on `:root` for the legacy
`.comfy-multiline-input` rule. This PR makes `WidgetTextarea` consume
the same variable via Tailwind v4's parenthesized CSS-variable
shorthand, keeping `GraphView` as the single source of truth.

- `text-xs` → `text-(length:--comfy-textarea-font-size) leading-normal`.
The `length:` type hint is required because `text-` is ambiguous between
`font-size` and `color`. `leading-normal` keeps line-height proportional
to font-size across the 8–24 px range so multi-line text doesn't clip at
the high end.
- Initialize `--comfy-textarea-font-size: 10px` on `:root` in the
design-system stylesheet so isolated renders (Storybook, tests) that do
not mount `GraphView` still pick up the documented default.

- Fixes
[FE-799](https://linear.app/comfyorg/issue/FE-799/bug-textarea-widget-font-size-setting-not-working-in-nodes-20)

## Verification

- `pnpm typecheck`, `pnpm lint`, `pnpm exec stylelint`, `pnpm exec oxfmt
--check`, `pnpm knip`, and `WidgetTextarea.test.ts` (20 tests) all pass.
- Manual browser verification with Vue Nodes 2.0 enabled and a
`CLIPTextEncode` node:
  - setting `8` → computed `font-size: 8px`
  - setting `22` → computed `font-size: 22px`
- setting `24` → computed `font-size: 24px`, computed `line-height:
36px` (ratio 1.5, no clipping)
- Confirmed the legacy LiteGraph path still resolves to
`comfy-multiline-input` with `font-size: 22px` when Vue Nodes is
disabled (no regression).
- Confirmed the `:root` default resolves to `10px` when `GraphView`'s
inline override is absent (Storybook-like environments).

## Out of scope (follow-up)

`WidgetMarkdown.vue` (the Vue Nodes 2.0 markdown/tiptap widget) also
hardcodes `text-sm`. The legacy `.comfy-markdown .tiptap` rule reads the
same `--comfy-textarea-font-size` variable, so the setting historically
governed markdown widgets in Nodes 1.0. Bringing that into line with
this PR's approach is a follow-up the design team should weigh in on
before changing.

## Screenshots


![textarea-fontsize-8px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449630-3046bbe9-cb29-41f7-8994-9d251bd0ab5d.png)


![textarea-fontsize-22px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449987-46aed4a1-b09c-4b2e-88cf-e6302944c319.png)


![textarea-fontsize-24px-multiline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307450337-164136c9-b1e2-4dac-8390-4d935d416675.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12386-fix-nodes-2-apply-Textarea-widget-font-size-setting-in-Vue-Nodes-2-0-3666d73d365081fd8084e84a41ee357b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-06 00:26:51 +00:00
Terry Jia
c695aa1ee0 fix(load3d): load Preview3DAdvanced / splat / pointcloud previews from temp/ (#12671)
BE is https://github.com/Comfy-Org/ComfyUI/pull/14294, need FE as well

Pair with backend
[BE-1172](https://github.com/Comfy-Org/ComfyUI/pull/14294). Two changes
bundled because both touch the same Preview3DAdvanced /
PreviewGaussianSplat / PreviewPointCloud

1. Backend now saves the incoming File3D into temp/ instead of output/
2. viewport input renamed from 'image' to 'viewport_state', Backend
renamed IO.Load3D.Input("image") to ("viewport_state") on the three
nodes (the field carries the viewport snapshot).
3. Exsting Load3D / Preview3D keep 'image' for workflow JSON
compatibility.
2026-06-05 20:04:09 -04:00
Willie
b907423526 feat: add Arrange action to multi-select toolbox (#12068)
## Summary

Adds an Arrange popover to the multi-select toolbox that repositions
selected nodes into vertical, horizontal, or grid layouts, with a
follow-up slider to tune the spacing.



https://github.com/user-attachments/assets/f9a55ef9-2619-462b-a83f-2b86eb076fe3


## Changes

- **What**: New `ArrangeButton` placed between the color picker and
frame icons. Three layouts (vertical, horizontal, grid) sort selected
nodes by current position and lay them out from the smallest-`x+y`
anchor with a 12-unit gap. Visual bounds account for
`LiteGraph.NODE_TITLE_HEIGHT` so titles don't overlap stacked bodies.
After picking a layout, the popover swaps to a 0–48 spacing slider —
drag previews live (rAF-throttled, no undo capture), release commits one
undo entry. Closing the popover ends the session.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

- `useArrangeNodes.computeArrangement` — pure layout math separated from
side effects; covered by 8 unit tests including the title-height
handling and `TitleMode.NO_TITLE` case.
- `useArrangeSession` — owns the slider's state machine. rAF-throttled
`previewGap` collapses rapid drag events into one frame; `commitGap`
cancels any pending preview before capturing undo. Covered by 5 unit
tests.
- Position mutations go through `useLayoutMutations().batchMoveNodes`
(single batched layout-store transaction) followed by
`changeTracker.captureCanvasState()` for undo — same pattern as drag and
other selection-toolbox actions.
- Anchor selection uses smallest `pos.x + pos.y` rather than min-x or
min-y alone, to keep the layout origin stable across re-runs (slider
drags).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12068-feat-add-Arrange-action-to-multi-select-toolbox-3596d73d365081e58c25ffac41dc0b2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-05 21:56:54 +00:00
AustinMroz
7d99189211 Ensure dropdowns display over selection toolbox (#12513)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/a7fc3432-3db3-40a5-b28e-11a309db76ce"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/a6a702cd-2ba3-4900-8afa-227cd9d61492"
/>|

Dropdowns were appended to self, but the selectionToolbox isappended to
body. As a result, changes to z-index on the dropdown would not allow it
to display above the selectionToolbox. Since dropdowns have been
migrated to reka-ui, dropdowns can now be safely appended to body as
well. Doing so cleans up a lot of no-longer-needed code. Of note
WidgetSelectDropdown seemed to never actually bind the `appendTo` and is
unaffected by the removal

As a secondary consequence of this change, dropdowns will no longer
scale with the current zoom level of the graph. Since litegraph would
not scale the size of popovers, this had been reported as a regression
by some users.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/0cb200c2-0811-4023-9ff1-aaa61113cbd5"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c82c087f-1f25-49c5-a85d-d9502b438526"
/>|
2026-06-05 21:49:03 +00:00
imick-io
5ddf5faef3 feat: scaffold Learning page with Featured Workflow, Tutorials, and CTA sections (#12602)
## Summary

Adds a new Learning page to the website with a hero, featured workflow
showcase, tutorials section, and CTA, wired into site nav and footer
Resources.

## Changes

- **What**:
- New `/learning` page (Astro) with `HeroSection`,
`FeaturedWorkflowSection`, `TutorialsSection`, and `CallToActionSection`
  - Localized for `zh-CN` at `/zh-CN/learning`
  - Featured workflow CTA links out to `comfy.org/workflows/<slug>`
- Added `nav.learning` translation; added Learning entry to `SiteNav`
and `SiteFooter` Resources
- New shared `PillButton`, `MaskRevealButton`, `Badge`, and
`VideoPlayer` work used by the page; `TutorialDetailDialog` for tutorial
deep-dives
  - Featured demo video updated; poster image added
  - `routes.ts`: added `learning` route entry
  - `EventsSection` temporarily hidden pending content

## Review Focus

- Copy on `learning.featured.description` (newly written, both `en` and
`zh-CN`)
- Tutorial data shape in `data/learningTutorials*.ts`
- Internal-vs-external link styling: Learning shows the active-page
yellow when viewing `/learning` (expected — internal route, no external
arrow)

## Screenshots (if applicable)

_Add deployment preview screenshots here._

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-05 18:11:14 +00:00
Terry Jia
4fe78997b0 fix(load3d): load Preview3DAdvanced output from temp/, allow temp loadFolder (#12661)
follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/12527
use temp folder for new Preview3DAdvanced node

BE https://github.com/Comfy-Org/ComfyUI/pull/14294
2026-06-05 10:41:45 -04:00
Dante
cda2929572 fix: use thumbnail previews for on-node image grid cells (FE-741) (#12561)
## Summary

On-node image grids rendered **full-resolution** `/api/view` images into
small grid cells. `nodeOutputStore.buildImageUrls` only appends a
thumbnail flag via `app.getPreviewFormatParam()`, which returns `''`
whenever `Comfy.PreviewFormat` is empty (the default on a fresh
install). So each grid `<img>` received the original URL with no
`&preview=`, and the browser downloaded + decoded the full asset into a
~98px cell.

Fix: grid cells request a lightweight thumbnail URL via a new
`getGridThumbnailUrl` helper — server-resized on cloud (`res`, mirroring
`ResultItemImpl.previewUrl`) and re-encoded to a compact format on every
backend (`preview=webp;75`). Gallery / full-view URLs stay at full
resolution; a URL that already carries a `preview` (user-set
`Comfy.PreviewFormat`) and SVGs (the server cannot rasterize them into a
preview) are left untouched. Scoped to the Vue `ImagePreview.vue` path;
the legacy canvas preview is deprecated.

- Linear: [FE-741](https://linear.app/comfyorg/issue/FE-741)

## Before / After (CDP, localhost:5173 → OSS backend, default settings)

Reproduced by rendering an on-node grid of 3 input images on a
`SaveImage` node. Measured the `/api/view` request for
`02_tangled_code.png` (2560×1440) in one ~98px grid cell:

| | Grid cell `<img>` src | Transfer | Type |
|---|---|---|---|
| **Before** | `…&filename=02_tangled_code.png&subfolder=&rand=…` |
**3,222,654 B (3.07 MB)** | `image/png` |
| **After** |
`…&filename=02_tangled_code.png&subfolder=&rand=…&preview=webp;75` |
**119,098 B (116 KB)** | `image/webp` |

**~27× less bandwidth** per cell (-96.3%). On cloud the URL additionally
gets `res=512` for a true server-side downscale.

| Before | After |
|---|---|
| <img width="430" alt="fe-741-before"
src="https://github.com/user-attachments/assets/677b19aa-1b89-4a22-a98f-79c122f5b9d6"
/> | <img width="430" alt="fe-741-after"
src="https://github.com/user-attachments/assets/6b4b30b0-14bb-4f19-8407-01f8e5de9143"
/> |

On-node rendering is pixel-identical (OSS re-encodes rather than
downscales) — no visual regression; the win is the transferred
bytes/format shown above.

## Red-Green Verification

| Commit | CI | Result |
|--------|----|--------|
| [`test:`
`b1b410b5`](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740459200)
| 🔴 Red | grid `<img>` still used the full-res URL → test
fails |
| [`fix:`
`2025cbe7`](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740850069)
| 🟢 Green | grid `<img>` requests a thumbnail → test passes
|

Follow-up `4fff42ed` hardens SVG handling and adds `getGridThumbnailUrl`
unit tests (CI green).

## Test Plan

- [x] CI red on test-only commit
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740459200))
- [x] CI green on fix commit
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740850069))
- [x] CDP before/after on a live on-node grid (network transfer 3.07 MB
→ 116 KB per cell)
- [x] Unit suites green: `ImagePreview` 31/31, `imageUtil` 10/10;
gallery/full-view URLs unchanged
2026-06-05 14:24:14 +00:00
jaeone94
82bea29dda fix: defer node auto-pan until drag starts (#12654)
## Summary

Fix a Vue node drag edge case where holding the partially off-screen
Advanced inputs button could continuously auto-pan the canvas even
though the pointer had not moved into an actual drag.

Linear:
[FE-938](https://linear.app/comfyorg/issue/FE-938/holding-partially-off-screen-advanced-inputs-causes-continuous)

## Changes

- **What**: Move Vue node auto-pan initialization from `startDrag()` to
`handleDrag()`, so auto-pan starts only after the pointer interaction
has become a real drag.
- **What**: Keep the existing auto-pan behavior during active drags by
creating the controller on the first `handleDrag()` call, updating its
pointer position on later drag frames, and preserving the existing
`onPan` position adjustments.
- **What**: Add unit coverage for the important drag lifecycle
invariants: no auto-pan on pointerdown/startDrag, auto-pan starts on
handleDrag, the same controller is reused across handleDrag calls, and
cleanup still stops auto-pan on endDrag.
- **What**: Add a Playwright regression that places the Advanced inputs
button partially beyond the visible canvas edge, holds the pointer down
without moving, and verifies the canvas offset stays stable.
- **What**: Add `data-testid="advanced-inputs-button"` to the Advanced
inputs footer button variants so the regression test does not depend on
translated button text.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

`useNodeDrag.startDrag()` created and started `AutoPanController`
immediately on pointerdown. When the Advanced inputs button was partly
outside the canvas bounds, a stationary pointer near the visible canvas
edge was enough for auto-pan to begin, even before any drag movement
occurred.

The pointer interaction layer already distinguishes press/hold from real
dragging before calling `handleDrag()`. Deferring auto-pan to
`handleDrag()` aligns auto-pan startup with that drag threshold and
prevents a plain hold from panning the canvas.

## Review Focus

- Auto-pan should not start from `startDrag()`/pointerdown alone.
- Auto-pan should still start promptly once `handleDrag()` runs for an
actual drag.
- Repeated `handleDrag()` calls should reuse the existing controller
rather than recreate it.
- Existing `onPan` behavior should continue to update drag start
positions, selected node start positions, selected groups, and node
positions during active drags.
- The E2E intentionally asserts the canvas offset, not node bounds,
because the reported bug is unintended canvas auto-pan while the pointer
is stationary.

## Red-Green Verification

- `a00b5d2fb test: add failing advanced button hold pan regression`:
adds the Playwright regression and test id plumbing. This was verified
red against the pre-fix production code.
- `5c207ae28 fix: defer node auto-pan until drag starts`: adds the
production fix and unit coverage. The same regression is verified green
with the fix.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit`
- `pnpm test:unit
src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts`
- `PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm
test:browser:local
browser_tests/tests/vueNodes/interactions/node/move.spec.ts --grep
"should not pan while holding the Advanced button without dragging"`
- Pre-push hook: `pnpm knip --cache`

## Screenshots (Before / After)

Before 


https://github.com/user-attachments/assets/6080de2d-e2da-4b38-a1ed-1f1f88548c2d

After 


https://github.com/user-attachments/assets/f331271a-9ea1-41ec-92cb-974bc57be56b
2026-06-05 05:26:18 +00:00
jaeone94
d129f757c0 fix: keep connected advanced inputs visible (#12652)
## Summary

Keep connected advanced widget inputs visible on Vue-rendered nodes when
advanced inputs are collapsed.

This fixes Linear
[FE-924](https://linear.app/comfyorg/issue/FE-924/bug-connected-advanced-input-parameters-become-hidden-when-advanced),
where a user could connect a noodle to an advanced input, collapse
advanced inputs, and then lose visual access to the connected parameter
even though it was actively used by the workflow.

## Changes

- **What**: Treat a widget-backed input as visible when its slot is
linked, even if the widget is advanced and the node-level advanced
section is collapsed.
- **What**: Move Vue node widget rendering to use the processed
`widget.visible` value instead of reimplementing visibility in
`NodeWidgets.vue`.
- **What**: Keep the visibility decision as a single source of truth
during widget processing, including the existing deduplication path.
- **What**: Add unit coverage for the new linked-widget visibility
behavior and the precedence rule that explicit hidden state still wins.
- **What**: Add an E2E regression that connects a `PrimitiveFloat` to
the advanced `max_shift` input on `ModelSamplingFlux`, collapses
advanced inputs, and verifies the connected input remains visible while
an unconnected advanced input remains hidden.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The key behavior is that linked advanced widgets should be promoted into
the visible widget set only while they are connected. Explicitly hidden
widgets must remain hidden even when linked.

The fix uses existing slot metadata from `useGraphNodeManager`; no new
graph state is introduced. This keeps the change scoped to Vue node
widget processing and rendering.

## Red-Green Verification

| Commit | Purpose | Local result |
| --- | --- | --- |
| `4fa5932c6` | Adds the E2E regression only | Red: `max_shift` was not
found after collapsing advanced inputs |
| `e5d1ee06a` | Adds the production fix and focused unit coverage |
Green: targeted E2E passed |

## Test Plan

- `pnpm test:unit
src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:5175
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm test:browser -g
"should keep connected advanced widgets visible when advanced inputs are
hidden" browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts`
- `pnpm typecheck && pnpm typecheck:browser`

## Screenshots (Before / After)
Before 


https://github.com/user-attachments/assets/bf1e88f3-2983-4bef-9cef-48ffe6dbfd6d



After 


https://github.com/user-attachments/assets/4dee7766-0252-478f-9b1c-4b801fc20eb2
2026-06-05 05:26:01 +00:00
jaeone94
874b486640 fix: resolve missing resource error messages (#12646)
## Summary

Resolve missing resource error groups through the error catalog so
missing nodes, replaceable nodes, missing models, and missing media use
consistent panel and single-error overlay copy.

## Changes

- **What**: Adds missing-resource resolvers for `missing_node`,
`swap_nodes`, `missing_model`, and `missing_media` that provide
`displayMessage`, `toastTitle`, and `toastMessage` alongside the
existing group titles. The Errors tab now renders a group-level
`displayMessage` under non-execution group headers, which gives grouped
missing-resource cards the same explanatory message path used by
validation/runtime errors without adding per-row detail fields that
these grouped cards do not need.
- **What**: Moves missing node and swap node explanatory copy out of
card-local hardcoded text and into `errorCatalog.missingErrors.*` keys.
`MissingNodeCard` and `SwapNodesCard` now focus on rendering their
grouped rows and actions, while the shared error group header owns the
explanatory copy.
- **What**: Adds environment-aware copy for missing node packs and
missing models. Cloud messages explain unsupported resources and
replacement/import paths without suggesting local execution, while OSS
messages point users toward installing or downloading the missing
resources.
- **What**: Adds single-error overlay/toast copy for missing resources.
Missing media uses a concise input-focused title/message, missing models
distinguish Cloud unsupported models from OSS missing files, and missing
nodes/swap nodes use node-type-aware copy.
- **What**: Deduplicates missing node and swap node toast decisions by
distinct node type so repeated instances of the same missing/replaceable
node do not accidentally switch the single-error copy to plural copy.
- **What**: Preserves representative missing media candidate metadata so
missing media toast copy can use a human-readable node name such as
`Load Image is missing a required media file.`
- **What**: Removes unused missing-resource resolver fields such as
grouped `displayDetails`, grouped `displayItemLabel`, and the unused
`mediaTypes` source parameter after deciding those fields do not fit
grouped missing-resource cards.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- Missing resource groups are still grouped cards. This PR intentionally
gives them group-level display and toast copy, but does not split
missing resources into one error item per underlying candidate.
- Missing resource count semantics are intentionally not normalized
here. Error overlay totals, store counts, and grouped card counts still
follow the existing behavior; a follow-up PR can define those count
units separately.
- The Cloud/OSS message variants remain explicit in the resolver instead
of being abstracted into a generic variant helper. That keeps this PR
focused on the messaging behavior and avoids a broader resolver
refactor.
- Only `src/locales/en/main.json` is updated directly. Other locales
should be synced by the existing localization flow.

## Screenshots (if applicable)

<img width="668" height="245" alt="스크린샷 2026-06-05 오전 3 16 49"
src="https://github.com/user-attachments/assets/98b50ac3-67e1-438d-8c37-e06c7bf465ee"
/>
<img width="666" height="195" alt="스크린샷 2026-06-05 오전 3 16 58"
src="https://github.com/user-attachments/assets/92da95b1-03d6-4739-97e6-c573982bfec9"
/>
<img width="505" height="358" alt="스크린샷 2026-06-05 오전 3 17 27"
src="https://github.com/user-attachments/assets/4d0e1a6e-13b9-4097-9fb5-19fe0c5331dc"
/>
<img width="507" height="324" alt="스크린샷 2026-06-05 오전 3 17 44"
src="https://github.com/user-attachments/assets/054e42f8-0d0c-44b5-8a67-e467fc04f1fc"
/>


## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm test:unit src/platform/errorCatalog/errorMessageResolver.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts`
- `pnpm typecheck`
- push hook: `knip --cache`
2026-06-05 05:25:46 +00:00
Matt Miller
1eede54582 fix(cloud): render the OAuth consent view in the dark theme (#12655)
## Problem

On the Comfy Cloud login flow, the OAuth / MCP consent modal
(`/cloud/oauth/consent`) renders with broken colors: a light-gray card
on the dark onboarding background, with a washed-out, barely-legible
title, section headers, and permission labels. Reported via internal QA.

## Root cause

The cloud onboarding shell (`CloudTemplate`) paints a dark surface using
hardcoded brand tokens (`primary-comfy-ink` / `primary-comfy-canvas`)
but never applies `.dark-theme` to the DOM. That class — which flips the
design-system **semantic** tokens to their dark values — is only set by
`GraphView` / `BaseViewTemplate`, neither of which mounts on the cloud
onboarding routes.

`OAuthConsentView` is the first onboarding view whose primary surface
(the card) is built on semantic tokens, so without a dark-theme context
they fall back to their light values:

| token | fell back to (light) | should be (dark) |
| --- | --- | --- |
| `secondary-background` (card) | `#e9e9e9` | `#262729` |
| inherited foreground (title / headers / labels) |
`primary-comfy-canvas` `#c2bfb9` on `#e9e9e9` → illegible | `#c2bfb9` on
`#262729` → legible |
| `muted` (subtitle / help / card border) | `#71717a` | `#a1a1aa` |
| `primary-background` (scope checks + CTA) | azure-400 `#31b9f4` |
azure-600 `#0b8ce9` |

The card sets no foreground of its own, so its title/headers/labels
inherit `primary-comfy-canvas` from the shell — fine on a dark card,
illegible on the light fallback one. The component markup is already
correct; it just never receives a dark-theme context. (The sibling
login/signup pages avoid this because they use hardcoded `primary-comfy`
/ `brand-yellow` tokens, not semantic ones.)

## Fix

Scope `.dark-theme` to the consent view's own root `<main>` — the same
class mechanism `BaseViewTemplate` uses for its `dark` prop. The card's
semantic tokens now resolve dark and the text is legible, while the
sibling login / signup / survey screens are untouched.

This is intentionally scoped to the reported view. Theming the whole
cloud onboarding shell (so any future semantic-token component there is
covered automatically) is a reasonable follow-up, but it shifts several
other views and has a wider blast radius, so it's left out of this fix.

## Preview (real component)

CI auto-deploys a Storybook preview of this branch to Cloudflare Pages
(see the **🎨 Storybook** comment below). The `Cloud/OAuth/Consent` story
renders the populated card with no backend — and with this change it
themes dark correctly:

**▶ [Cloud/OAuth/Consent — live
story](https://fbae9a69.comfy-storybook.pages.dev/?path=/story/cloud-oauth-consent--single-workspace)**

And the fix on the **live app**, captured in a preview environment via a
real OAuth flow:

![OAuth consent screen — dark-theme
fix](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/mattmillerai/oauth-consent-screenshot/oauth-consent-fixed.png)

## Verification

The `Cloud/OAuth/Consent` Storybook story renders the real component;
switching the Storybook theme global from **light → dark** reproduces
this exact before/after. This change makes the live route match the dark
rendering. Existing `OAuthConsentView.test.ts` assertions are on
text/roles and are unaffected by the added class.

## ELI-5

The "allow access?" popup was wearing light-colored clothes inside a
dark room, so the words blended into the card and you couldn't read
them. This tells the popup it's in a dark room, so it dresses for the
dark and the text shows up again. Nothing else in the room changes.
2026-06-04 21:53:45 +00:00
Terry Jia
be131f7e9a feat(load3d): register Preview3DAdvanced extension (#12527)
## Summary
Preview3DAdvanced is a Preview3D variant that emits live viewport state
as outputs (model_file, camera_info, model_3d_info, width, height) plus
a width/height pair. Reuses the LOAD_3D capture widget for the viewport
but
skips upload/clear buttons (it has no model_file widget). 

Adds a camera-only + gizmo serializeValue path (no scene/mask/normal
image uploads since no image outputs are emitted) and an onExecuted that
loads the saved preview model via
Load3DConfiguration.configureForSaveMesh and applies the input
camera_info forwarded via PreviewUI3D when one was connected, so a chain
of Preview3DAdvanced nodes propagates camera state in a single run
instead of lagging one hop per execution.

Drops the per-node camera lock toggle (retainViewOnReload) across all 3D
nodes (Load3D, Preview3D, Preview3DAdvanced).
The viewport now always preserves the user's adjusted framing after the
first model load; the toggle was rarely needed and made chained
propagation ambiguous. Saved workflows with retainViewOnReload in their
cameraConfig continue
to load, the field is simply ignored at runtime.

camera_info / model_3d_info resolved by execute: the input when
connected, otherwise the viewport's own live state. Applying them
overrides the preserved viewport in the input case and is a no-op echo
when the viewport already matches. Only the first model_3d_info entry is
applied, single-object viewer currently.

BE: https://github.com/Comfy-Org/ComfyUI/pull/14175

## Screenshots (if applicable)


https://github.com/user-attachments/assets/42536469-e188-492a-9864-cdb6bfa76e97
2026-06-04 18:09:12 -04:00
pythongosssss
daf07a7442 fix: node library drag drop to add node appears in wrong place in firefox (#12419)
## Summary

Firefox can give invalid drag coordinates causing incorrect drop
position (https://bugzilla.mozilla.org/show_bug.cgi?id=1773886)
I was unable to consistently recreate this issue and it only happened in
Firefox, so not a candidate for e2e tests.

## Changes

- **What**: 
- store position on `dragover` which looks to reliably report the
correct position and use that value on drop

## Screenshots (if applicable)



https://github.com/user-attachments/assets/6ff604b7-92fb-4a70-bd9f-c37cdba292ba
2026-06-04 19:50:41 +00:00
Dante
454e124099 feat(graph): add mask icon to "Open in Mask Editor" context menu option (FE-929) (#12642)
## Summary
<img width="1600" height="882" alt="fe-mask-icon-after"
src="https://github.com/user-attachments/assets/7ddd6ae3-4c6a-4c4d-9a61-2059851bd4f9"
/>


Stacked on #12563 (FE-839). Follow-up requested in the FE-839 thread by
@AlexisRolland and @alextov: the media-node context menu gives every
image action an icon **except** `Open in Mask Editor`, leaving its label
misaligned with the rest of the group.

This reuses the existing custom `comfy--mask` icon — the same one shown
on the node image overlay / selection-toolbox `MaskEditorButton` — for
the `Open in Mask Editor` entry. With it, all five image actions (`Open
Image`, `Open in Mask Editor`, `Copy Image`, `Paste Image`, `Save
Image`) now carry an icon and their labels line up.

- Follow-up to FE-839
- Context (FE-839 Slack thread): Alexis — *"it would be nice to have an
icon for open in mask editor. There is one on the image overlay in the
node which could be reused"*; Alex Tov — icon name is `mask`, a custom
icon.

> The broader idea of reserving a placeholder/buffer for options that
have no icon so **all** menu labels align (Alex Tov) is intentionally
out of scope here — this PR only completes the image-action group.

## Implementation

- `useImageMenuOptions.ts`: add `icon: 'icon-[comfy--mask]'` to the
`Open in Mask Editor` option.

No rendering changes are needed — `NodeContextMenu.vue` already renders
the icon via `<i v-if="item.icon" :class="[item.icon, 'size-4']" />`,
and `comfy--mask` is already registered (custom collection loaded from
`packages/design-system/src/icons`, used today by `MaskEditorButton.vue`
and `linearMode/DropZone.vue`).

## Red-Green Verification (local)

| Step | Result |
|------|--------|
| Test-only commit (`d8b7e2c`) | 🔴 Red — `expected undefined to be
'icon-[comfy--mask]'`; image group not fully iconed |
| Fix commit (`feeb505`) | 🟢 Green — 10/10 passing |

## Before / After

Right-click a media node (Load / Preview / Save Image) → image-action
group at the top of the menu:

| Before (base `FE-839`) | After (this PR) |
| --- | --- |
| `Open in Mask Editor` has no icon — its label sits flush-left while
the other four image actions are indented past their icons. | `Open in
Mask Editor` shows the mask icon; all five image actions line up. |

<!-- drag fe-mask-icon-before.png and fe-mask-icon-after.png into the
two cells above -->

Captured locally against a Load Image node with `example.png` (Vue nodes
enabled), dev server proxied to a live backend.


https://comfy-organization.slack.com/archives/C0A7ADM4797/p1780467176353799?thread_ts=1779462362.995709&cid=C0A7ADM4797
## Test Plan

- [x] Local red on the test-only commit
- [x] Local green on the fix commit
- [x] `useImageMenuOptions.test.ts` — 2 new tests (mask icon present on
`Open in Mask Editor`; every image action carries an icon)
- [x] eslint clean on changed files
- [ ] Manual: right-click a media node and confirm the mask icon renders
next to `Open in Mask Editor`
2026-06-04 18:40:10 +00:00
Matt Miller
8a819fa2be refactor(assets): read content hash from the canonical hash field (#12638)
## Summary
The assets API exposes an asset's content hash as `hash`. An older
`asset_hash` field was a deprecated alias carrying the same value. This
PR moves the frontend fully onto `hash` and removes `asset_hash` from
the frontend entirely.

## Changes
- Read `asset.hash` (no `?? asset_hash` fallback) across the asset
consumers:
- `useMediaAssetActions` — widget-value variants + cloud-mode
stored-filename resolution
  - `assetsStore` — input-asset-by-filename map
  - `assetMetadataUtils.getAssetUrlFilename`
  - `missingMedia` resolver/scan and `missingModel` scan hash matching
  - `useComboWidget` / `useWidgetSelectItems`
- `assetPreviewUtil.findOutputAsset` now queries `/assets?hash=` instead
of the deprecated `?asset_hash=` param and matches on `a.hash`.
- Removed `asset_hash` from the zod asset schema and the local
`AssetRecord` type. Responses that still include the alias parse cleanly
— zod strips unknown keys — so the declared field protected nothing once
the reads were gone.
- Purged `asset_hash` from all test fixtures/mocks; tests key on the
canonical `hash`.

## Safety / rollout
The API currently emits **both** `hash` and `asset_hash` with identical
values, so reading `hash` is safe today. This is the frontend half of
retiring the alias; the backend stops emitting `asset_hash` only after
this ships and old bundles age out, so there is no window where the
field the UI reads is absent.

## Verification
- `pnpm typecheck`: clean.
- Affected unit tests pass (asset utils, store, media/model scans,
widget composables).
- `grep -rn asset_hash src/`: zero matches.
2026-06-04 18:18:12 +00:00
Deep Mehta
35157f1af0 feat(telemetry): capture desktop entry props in cloud build (#12647)
## Summary

When a visitor arrives at the cloud product with
`utm_source=comfy.desktop`, register `source_app` and
`desktop_device_id` as PostHog super-properties and persist them onto
the person on identify.

Backend-fired billing events (Stripe webhook →
`billing:subscription_created`) then inherit the desktop attribution via
person-on-events, closing the cross-session gap where browser-side utm
capture and backend webhook events live on different `distinct_id`s.

## Why

The desktop app appends
`?desktop_device_id=<id>&utm_source=comfy.desktop` to its cloud links.
`utm_source` is auto-captured by posthog-js as a session super-property,
but arbitrary query params like `desktop_device_id` are ignored. Without
this, cross-session Desktop→Cloud sub attribution silently shows zero.

Confirmed empirically: across 24h of recent billing events,
`person.$initial_utm_source = 'comfy.desktop'` matched 0 rows, because
users had visited the cloud surface before they ever launched desktop.
Person-property based join via this PR is the durable fix.

## Test plan

- [x] Unit tests for: no utm, wrong utm, utm with device id, utm without
device id, person.set after identify, no-op for non-desktop visitors
- [x] `pnpm vitest run PostHogTelemetryProvider.test.ts` — 28/28 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual verification post-merge: visit cloud.comfy.org with
`?utm_source=comfy.desktop&desktop_device_id=test-abc`, log in, check
PostHog person profile gains `desktop_device_id`,
`last_seen_via_desktop`, `first_seen_via_desktop`, `source_app`
2026-06-04 18:17:58 +00:00
jaeone94
f9cbaf750f fix: simplify error overlay messaging (#12598)
## Summary

Simplifies the error overlay so it presents one clear title, one clear
message, and one stable details action instead of rendering a list of
per-error messages.

## Changes

- **What**: Extracts the error overlay view model into
`useErrorOverlayState`, adds focused unit coverage for the overlay copy
resolution rules, and updates the overlay E2E coverage to match the new
behavior.
- **Breaking**: None.
- **Dependencies**: None.

### Behavior changes

- The overlay body no longer renders a `<ul>` of individual error
messages. It now always renders a single paragraph message.
- Single-error overlays now prefer toast-specific copy when it exists.
For execution errors, the overlay resolves the message in this order:
`toastMessage`, `displayMessage`, raw `message`, group `displayMessage`,
then group `displayTitle`. The title resolves from `toastTitle`, then
`displayTitle`, then the group title.
- Single non-execution groups use group-level toast/display copy. This
lets grouped error types supply overlay-friendly copy without the
overlay needing to understand each card implementation.
- Multiple-error overlays now ignore individual error item copy in the
overlay itself. The header becomes the pluralized count title, for
example `7 errors found`, and the body becomes the fixed guidance
message: `Resolve them before running the workflow.`
- The overlay is hidden if the store reports an error count but no
resolved overlay message exists. This avoids rendering a visible shell
with an empty body.
- The action button no longer varies by error type in normal app mode.
Missing nodes, missing models, missing media, swap nodes, validation
errors, and runtime errors all use `View details` instead of labels like
`Show missing nodes`, `Show missing models`, `Show missing inputs`, or
`See Errors`.
- App mode keeps its existing `Show errors in graph` action label.
- The overlay width now keeps the previous width as its minimum and
allows a wider maximum, reducing avoidable wrapping in longer error
headers.
- The live region was softened from an assertive alert-style
announcement to `role="status"` with `aria-live="polite"` so updates
such as count changes are less disruptive.

### Tests

- Adds component coverage for the rendered overlay shape and app-mode
action label.
- Adds composable coverage for single execution errors, runtime errors,
grouped missing media errors, multiple-error aggregate copy, hidden
empty-message state, and display-copy fallback behavior.
- Updates `errorOverlay.spec.ts` so the E2E suite checks the new
single-message overlay, the stable `View details` action, and the fixed
multiple-error body guidance.
- Removes the old type-specific button-label E2E expectations because
that branch no longer exists in product behavior.

### Follow-up PR

A follow-up PR is stacked on top of this one:
`jaeone/fe-816-missing-resource-error-messaging`.

That follow-up will wire missing resource error resolvers into the copy
model consumed here. It covers missing node packs, missing models,
missing media, and swap-node groups, including the group-level
`toastTitle`, `toastMessage`, `displayMessage`, `displayDetails`, and
item label copy those cards need. This PR intentionally keeps the
overlay behavior separate so it can merge first without depending on the
missing-resource resolver copy.

## Review Focus

- Please check the single-error versus multiple-error overlay behavior,
especially the fallback order for execution error copy.
- Please check that the `View details` action is now intentionally
error-type agnostic in normal app mode while app mode keeps `Show errors
in graph`.
- Please check the empty-message guard and the requirement that a
single-error overlay only resolves a single group when the total error
count and group list agree.
- Please check the E2E reduction: the old type-specific action-label
assertions were removed because the UI branch they tested was removed.

## Screenshots (if applicable)

N/A
2026-06-04 06:28:14 +00:00
jaeone94
4bfb0c36be Fix Cloud media input defaults (#12562)
## Summary

Fix Cloud media loader widgets so `LoadImage`, `LoadVideo`, and
`LoadAudio` resolve their default values from Cloud input assets instead
of blindly accepting backend `object_info` combo options. When no
matching Cloud input asset exists, the widgets now start empty instead
of selecting a server-only value that immediately trips missing-input
detection.

## Changes

- **What**: Cloud media input widgets now derive their available values
from `assetsStore.inputAssets`, filtered to the node's media type and to
assets with a valid `asset_hash`.
- **What**: Cloud media defaults now prefer an explicit default only
when it matches an available Cloud input asset hash or name, then fall
back to the first matching Cloud input asset, and otherwise use an empty
value.
- **What**: The media path keeps the existing model-widget
implementation style by resolving Cloud asset state through the store
internally, while preserving media-specific hash/name matching because
media widgets submit Cloud `asset_hash` values.
- **What**: Added regression coverage for adding empty Cloud
`LoadImage`, `LoadVideo`, and `LoadAudio` nodes when backend
`object_info` advertises server-only media options.
- **What**: Expanded media widget unit coverage for image/video/audio
inputs, empty defaults, first-asset fallback, default-by-hash matching,
default-by-name matching, hashless asset filtering, unrelated media
filtering, dynamic values, option labels, lazy input loading, and
`control_after_generate` wiring.
- **What**: Kept existing OSS and Cloud runtime missing-media E2E
coverage, with Cloud fixtures consistently using the local setup backend
for `object_info` so tests do not depend on live Cloud backend startup
details.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- Cloud media loader defaults should no longer be sourced from backend
file lists unless the value also corresponds to a Cloud input asset.
- Empty Cloud input-asset libraries should produce empty media widget
values, not missing-input errors at node creation time.
- Model asset-browser behavior is intentionally unchanged. The media
path mirrors the store-access style, but media defaults still resolve to
asset hashes because those are the values submitted by Cloud media
widgets.
- The Cloud E2E fixture stubs bootstrap endpoints and routes
`object_info` through the local setup backend. This keeps the test
focused on frontend behavior while still using realistic node
definitions.

## Testing

- `pnpm exec oxfmt
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm exec vitest run
src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts`
- `PLAYWRIGHT_TEST_URL=http://127.0.0.1:5174
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=chromium --workers=1`
- `PLAYWRIGHT_TEST_URL=http://127.0.0.1:5175
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --workers=1`
- `git diff --check`
- `pnpm knip`
- `.claude/skills/reviewing-unit-tests/SKILL.md` red-flag review

## Screenshots
Before


https://github.com/user-attachments/assets/5df04036-d15c-4f94-bdcd-df8b26a29329

After 


https://github.com/user-attachments/assets/abe7caf5-a83b-4960-aa6f-65a377424a85
2026-06-04 06:27:42 +00:00
Dante
b996ed3ab5 fix: surface image actions at top of node context menu (FE-839) (#12563)
## Summary

On media nodes (Load/Preview/Save Image), the right-click context menu
buried the image actions at the very bottom — and `Open Image` sat
*second* within that group, behind `Open in Mask Editor`. In legacy
LiteGraph (Node 1.x) `Open Image` was the first menu item
(`options.unshift`), so the Vue menu was a regression in muscle memory.

This hoists the image-action group (`Open Image`, `Open in Mask Editor`,
`Copy Image`, `Paste Image`, `Save Image`) to a new top section of the
menu, with `Open Image` first. Because these labels only ever appear for
media nodes, non-media node menus are unchanged (keeps the core menu
order stable — per Alex Tov's request in the FE-839 thread).

- FE-839
- Related: DES-368 (lightbox idea this replaces)

## Implementation

- `contextMenuConverter.ts`: move the image group to the front of
`MENU_ORDER` (new "Section 0"), shift `getSectionNumber` boundaries, and
change the section-divider sentinel `lastSection` from `0` to `-1`
(Section 0 is now a real section, so the old `> 0` guard would have
dropped the divider before `Rename`).
- `useImageMenuOptions.ts`: push `Open Image` before `Open in Mask
Editor` for source/display consistency.

## Red-Green Verification

| Commit | Unit CI | Result |
|--------|---------|--------|
| `test: add failing tests …` | [run
26749384308](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26749384308)
| Red — fails on `expected 'Rename' to be 'Open Image'` |
| `fix: surface image actions …` | [run
26749817184](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26749817184)
| Green — passing |

## Before / After

Right-click on a media node (Load Image with `example.png`):

- **Before:** `Open Image` buried near the bottom, second within its
group.
- **After:** image actions group surfaced at the top, `Open Image`
first; core order (Rename/Copy/…) unchanged below the divider.

| Before (`main`) | After (this PR) |
| --- | --- |
| <img width="460" alt="Before: image actions buried at the bottom of
the menu, Open in Mask Editor above Open Image"
src="https://github.com/user-attachments/assets/2d0a6345-6cbb-4391-8cee-60632a6af72f"
/> | <img width="460" alt="After: image actions hoisted to the top of
the menu, Open Image first"
src="https://github.com/user-attachments/assets/6af0840f-0623-41f2-9fa1-b4a16798f9a5"
/> |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit regression in `contextMenuConverter.test.ts` (6 tests: Open
Image first / image group above Rename / divider between /
Copy<Paste<Save / core order preserved / non-media unchanged)
- [x] Manual verification in live app (before/after)
2026-06-04 02:24:47 +00:00
Robin Huang
dc46519fa7 feat: add app:node_added telemetry event (#12615)
## Summary

Adds a new `app:node_added` PostHog event that fires whenever a user
adds a node to the canvas, tagged with a `source` discriminator
(sidebar_drag, search_modal, paste, programmatic, unknown). Lets us
measure how users compose graphs — which we can't infer from any
existing event.

## Changes

- **What**: Subscribes to `LGraph.onNodeAdded` via
`installNodeAddedTelemetry()`. Source is threaded through call sites via
a synchronous module-level flag (`withNodeAddSource`) — `addNodeOnGraph`
signature is unchanged.
- Wired sources: sidebar drag (3 sidebars + canvas drop), search-modal
popover, vintage clipboard paste, programmatic adds (job menu, media
asset actions). Unrecognized paths fall through as `unknown`.
- **Skip on workflow load**: `ChangeTracker.isLoadingGraph` gates out
the bulk-add path. `workflow_imported` already covers that population,
and 4M imports/month × ~50 nodes would dwarf user-initiated signal.

## Review Focus

- Module-level flag is synchronous-only — the source is read inside the
synchronous `onNodeAdded` callback that fires during `graph.add()`.
Async code (e.g. `createNode`'s `await setTimeout(0)`) doesn't
interleave because the wrap is around `graph.add`, not the await.
- Unit test covers: fires with current source, defaults to 'unknown',
skips during workflow load, preserves existing onNodeAdded subscriber.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 01:54:56 +00:00
Comfy Org PR Bot
28837b8913 1.46.9 (#12637)
Patch version increment to 1.46.9

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-04 01:54:09 +00:00
Terry Jia
6d495f5642 FE-906 fix(vueNodes): require dragGuard.wasDragged for multi-select drag-start (#12623)
## Summary
The multi-select branch of onPointermove started a node drag whenever
LMB + shift/ctrl/meta was held during pointermove, even when pointerdown
had never reached the node (because a child widget swallowed it with
@pointerdown.stop). In Preview3D/Load3D, shift+drag inside the threejs
scene therefore dragged the underlying Vue node.

Gate the multi-select branch on dragGuard.wasDragged(event) — same gate
the regular branch already uses. dragGuard.start is only recorded by
onPointerdown, so a widget that swallows pointerdown now naturally opts
itself out of node drag-start without having to also swallow pointermove
(which would break libraries like three.js OrbitControls that listen for
pointermove on document).

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/b5faa803-5993-4341-a1d0-7ec980362344

after

https://github.com/user-attachments/assets/f7367e61-6194-4e16-9713-c398f989d26b
2026-06-03 21:46:32 -04:00
Dante
f86ffbb05f fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#11716)
## Summary

When the cloud `getJobDetail` returns two output records that resolve to
the same composite key `${nodeId}-${subfolder}-${filename}`,
`mapOutputsToAssetItems` in
`src/platform/assets/utils/outputAssetUtil.ts` produces two `AssetItem`s
with the same synthetic id. The Vue `v-for :key="item.key"` in
`src/components/common/VirtualGrid.vue:10` collides, Vue reuses one DOM
node for the colliding rows, and the user sees one asset visibly
duplicate and progressively replace its neighbours while scrolling
through an expanded large job in the media asset panel — symptom matches
FE-297 in both list and grid views (both views derive from the same
`displayAssets` populated by `resolveOutputAssetItems`).

Fix tracks composite keys per resolved job and skips subsequent records
that collide on the same key. Treats the composite key as the canonical
identity of an output, so each rendered row is unique in both views
without changing public id semantics for non-colliding inputs.

- Fixes FE-297
- Source: [Slack
#bug-dump](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1777047001770899)

## Red-Green Verification (unit)

| Commit | Purpose | CI |
| --- | --- | --- |
|
[`b263af29b`](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11716/commits/b263af29b)
test: FE-297 add failing test for asset id collision on duplicate output
key | Proves the test catches the regression | 🔴 [Red run
25038514049](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25038514049)
— `FE-297: deduplicates outputs that share the same composite output
key` failed with `expected length 2 but got 3` |
|
[`af38cad1d`](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11716/commits/af38cad1d)
fix(assets): dedupe outputs by composite key to prevent visual collapse
on scroll | Proves the fix resolves it | 🟢 [Green run
25038832343](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25038832343)
— all unit tests pass |

Local verification (`pnpm vitest run
src/platform/assets/utils/outputAssetUtil.test.ts`):
- On red commit: `FE-297: deduplicates outputs that share the same
composite output key` fails with `expected length 2 but got 3`.
- On green commit: all 8 tests pass; broader `pnpm vitest run
src/platform/assets/` reports 390/390 pass.

## Browser-level Verification (e2e)


[`c5f80e225`](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11716/commits/c5f80e225)
adds `browser_tests/tests/sidebar/assets-fe297-dedupe.spec.ts`. The spec
mocks `/api/jobs` + `/api/jobs/{jobId}` so the assets sidebar receives a
5-output stack job whose detail payload contains two records sharing
`9--duplicate_00002_.png`. It expands the stack into folder view and
reads the underlying `VirtualGrid` row total from the top/bottom spacer
heights so the assertion does not depend on viewport size, scroll
virtualization, or Vue's same-key DOM reuse.

Locally validated red→green (`pnpm dev` + local ComfyUI on :8188, `pnpm
exec playwright test … --project=cloud`):

| Source | `totalRows` | rendered tile labels | result |
| --- | --- | --- | --- |
| Red (dedup removed from `mapOutputsToAssetItems`) | **5** |
`[duplicate_00002_.png, duplicate_00002_.png, distinct_00004_.png]` (two
adjacent collisions) | 🔴 `Expected: 4 / Received: 5` |
| Green (HEAD) | **4** | `[duplicate_00002_.png, distinct_00004_.png,
distinct_00003_.png]` (no collisions) | 🟢 `1 passed` |

Manual repro of the same flow against the running dev server (Chrome
DevTools, runtime `fetch` interceptor for `/api/jobs/<id>`) reproduces
the FE-297 symptom directly: with the fix removed, the folder view
renders **two consecutive cards both labelled `duplicate_00002_.png`**;
with the fix applied that second slot is replaced by the next distinct
file.

## Cloud Prod Verification (unmocked)

Verified against a real cloud prod job
(`22dda683-1634-4120-8a7d-233cff28e07e`) whose `/api/jobs/{id}` payload
organically contains the FE-297 trigger condition.

Raw cloud response analysis: **35 output records across 27 nodes, 34
unique composite keys, 1 colliding key**
(`103--cbbce08934d17e85987b09824ae519822e4462b4294fc52fc40dca8d5b096323.png`
appears twice — same nodeId/subfolder/filename emitted from two distinct
output records).

Expanded folder view on local FE running this branch against cloud prod
backend:

| Measurement | Value | Expectation |
| --- | --- | --- |
| `outputCount` (backend) | 35 | — |
| Unique composite keys (backend) | 34 | (35 - 1 collision) |
| VirtualGrid rendered cells (`totalRows * colCount` via spacer height)
| **34** | matches dedupe target |
| Simultaneously-visible duplicate labels | 0 | no Vue `:key` collision
|

This is the first visual confirmation of FE-297 against actual prod data
rather than synthetic/mocked payloads. The block had been pending on
FE-844 — cloud `/api/jobs/{id}` returns `text: [null]` for empty
subgraph promoted text outputs, which the FE Zod schema rejects, causing
`fetchJobDetail` to return `undefined` and the folder view to collapse
to the cover preview only. With [PR
#12449](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12449)
(FE-844) applied locally, the job-detail response reaches
`resolveOutputAssetItems`, FE-297's dedupe runs, and the 35→34 collapse
is observable end-to-end.

## Test Plan

- [x] New unit regression covering composite-key collision in
`mapOutputsToAssetItems`
- [x] Existing `outputAssetUtil` tests still pass (`job-1-1-sub-a.png`
etc. id format unchanged for non-colliding inputs)
- [x] Broader asset platform suite (`src/platform/assets/`) passes
- [x] CI red-green sequence captured (links above)
- [x] Browser-level e2e (`assets-fe297-dedupe.spec.ts`) red→green
validated locally; will run in CI on this commit
- [x] Manual repro on dev server confirms the duplicate-card symptom
disappears with the fix
- [x] Unmocked cloud-prod repro: real 35-output job with 1 organic
composite-key collision renders 34 cells, 0 duplicate labels
simultaneously visible (requires
[#12449](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12449) for
job-detail fetch to succeed)
2026-06-04 01:28:02 +00:00
Deep Mehta
6e7c4f85fe fix: update PWA/app icon to new brand colors (#12632)
## Summary

cloud.comfy.org shows the old yellow-on-blue ComfyUI mark as its
favicon. Root cause: `manifest.json` points at `comfy-logo-single.svg`
(old `#172DD7` blue logo), and older deployed builds set no static
`<link rel="icon">`, so the browser uses the manifest icon for the tab.
Point the manifest at new-brand dark+yellow icons.

## Changes

- **What**: `manifest.json` icons now reference new `comfy-icon-192.png`
/ `comfy-icon-512.png` (dark `#211927` + yellow `#F2FF59`, full-bleed
for `maskable` safe zone) instead of the old blue
`comfy-logo-single.svg`.
- **Breaking**: none.

## Review Focus

- Pairs with #12537 (static `favicon.ico` link, merged) — together the
tab favicon and the PWA/install icon are both the new brand.
- This does NOT change `comfy-logo-single.svg` itself, which is still
used as the in-app header logo (`ComfyOrgHeader.vue`) and the cloud
subscription redirect view — those remain the old blue mark and can be
updated separately if desired.
- Note: cloud.comfy.org runs a pinned/older frontend build; this (and
#12537) only take effect once cloud bumps its frontend version.

## Screenshots (if applicable)
2026-06-04 00:39:02 +00:00
Alexander Brown
11a621152c ci: skip PR Unified Report on main branch (#12631)
*PR Created by the Glary-Bot Agent*

---

## Summary

The
[`pr-report.yaml`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/.github/workflows/pr-report.yaml)
workflow is dispatched via `workflow_run` after `CI: Size Data`, `CI:
Performance Report`, or `CI: E2E Coverage` complete. Those upstream
workflows also run on push to `main`, which dispatches `PR: Unified
Report` on the `main` branch head, producing failed runs like [run
26913874399](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26913874399/job/79398707144).

The existing job-level guard (`github.event.workflow_run.event ==
'pull_request'`) doesn't always prevent the runner from spinning up, so
noisy failed runs still appear under the workflow's history on `main`.

## Change

Add `branches-ignore: [main]` to the `workflow_run` trigger so the
workflow no longer dispatches when the upstream run's head branch is
`main`.

```yaml
on:
  workflow_run:
    workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
    types:
      - completed
    branches-ignore:
      - main
```

## Tradeoff to flag

`workflow_run.branches-ignore` filters on the triggering workflow run's
head branch. For PRs, that's the PR's source branch. A fork PR whose
source branch is literally named `main` (e.g., a fork opened from its
default branch) will no longer get a unified report comment. This is
uncommon for contributors who follow the usual feature-branch workflow,
but if preserving comments for fork-from-`main` PRs matters, the
alternative is to leave the dispatch in place and rely solely on the
existing job-level `if` guard.

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-03 21:58:39 +00:00
Alexander Brown
6fe50a3685 chore: centralize pnpm Node options (#12626)
## Summary

Centralize pnpm-run Node options so Node 25 does not shadow happy-dom
storage and build scripts keep the existing heap limit.

## Changes

- **What**: Adds workspace-level `nodeOptions` that preserve
caller-provided `NODE_OPTIONS`, disable Node's native Web Storage, and
set `--max-old-space-size=8192`.
- **What**: Removes duplicate script-local heap `NODE_OPTIONS` from
build scripts now covered by pnpm.
- **Dependencies**: None.

## Review Focus

Check whether applying these Node options to all pnpm-run Node
subprocesses is acceptable versus keeping flags script-local.

## Screenshots (if applicable)

Not applicable.
2026-06-03 21:32:20 +00:00
Alexander Brown
488bc33288 refactor: drop primevue/colorpicker from settings form and customization selector (FE-804) (#12391)
*PR Created by the Glary-Bot Agent*

---

## Summary

The node-canvas COLOR widget (`WidgetColorPicker.vue`) already migrated
off PrimeVue; this PR finishes FE-804 by porting the two remaining
`primevue/colorpicker` consumers — `FormColorPicker.vue` (settings form
`type: 'color'`) and `ColorCustomizationSelector.vue`
(folder-customization dialog) — to the in-house Reka-UI based
`ColorPicker`. The now-dead PrimeVue overlay workaround in
`CustomizationDialog.vue` is removed.

After this lands there are **zero `primevue/colorpicker` imports left**
in `src/`.

## Changes

- **What**: `FormColorPicker.vue` swaps `primevue/colorpicker` +
`primevue/inputtext` for the in-house `ColorPicker` + `Input`. The
legacy "hex without `#`" storage contract (e.g. `load3d`'s
`BackgroundColor` default `'282828'`) is preserved on read and on write.
- **What**: The text input now uses a separate draft value and only
commits on blur / Enter when the input is a complete 6- or 8-digit hex.
This fixes the "type `#f` and watch it snap to black" regression that a
naive shared-`v-model` implementation re-introduces.
- **What**: `disabled`, `id`, and `aria-labelledby` are now explicit
props on `FormColorPicker` and are forwarded to both children. The
custom `ColorPicker` learned a `disabled` prop that propagates to its
`<PopoverTrigger>` button.
- **What**: `ColorCustomizationSelector.vue` swaps
`primevue/colorpicker` for the in-house `ColorPicker` (still uses
`primevue/selectbutton` — intentionally out of scope per FE-804's title;
`SelectButton` migration is a separate effort).
- **What**: `CustomizationDialog.vue` drops the `.p-colorpicker-panel,
.p-overlay, .p-overlay-mask` `pointer-down-outside` guard. With PrimeVue
ColorPicker gone, no descendant of this dialog teleports an overlay to
`<body>` anymore.
- **What**: Updates two affected browser tests — `extensionAPI.spec.ts`
(the `disabled` attr smoke check) and `sidebar/nodeLibrary.spec.ts` (the
bookmark color customization flow) — to target the new picker via stable
accessible names (`role="slider"` + i18n aria-label `Color saturation
and brightness`) and the `.color-picker-wrapper > button` trigger. The
disabled-attr eval helper now handles `HTMLButtonElement` in addition to
`HTMLInputElement`.
- **What**: Adds `FormColorPicker.test.ts` with focused regression
coverage for the manual-entry contract: legacy no-`#` storage
round-trip, no commit on partial hex, revert on partial-then-Enter,
8-digit alpha hex, and `disabled` propagation.
- **Dependencies**: none added; removes two PrimeVue imports.
- **Breaking**: no breaking change to the documented FormItem `'color'`
setting contract. Manual-entry semantics change: typing partial hex no
longer immediately writes mangled state — it commits on blur or Enter
when the value fully parses. Existing settings values are unaffected.

## Verification

- `pnpm typecheck` clean
- `pnpm typecheck:browser` clean
- `pnpm exec eslint` on every touched file clean
- `pnpm test:unit` over the affected directories — **216 passed**
- Manual QA via Playwright against the running dev server:
- Registered a test extension with `type: 'color'` + a `disabled: true`
variant
- Confirmed the new picker renders, opens its Reka popover, and the
disabled row has `button.disabled === true` and `input.disabled ===
true`
  - Confirmed partial hex (`#ab`) does **not** clobber the swatch
- Confirmed `#1133aa` + blur commits and round-trips through the picker

## Review focus

1. The manual-entry commit gate in `FormColorPicker.vue`
(`commitDraft()` + `FULL_HEX`) — is the regex strict enough? Should
3/4-digit shorthand hex be accepted on commit too? PrimeVue accepted
3-digit shorthand; the existing `toHexFromFormat()` already does, so
adding `|[0-9a-f]{3}|[0-9a-f]{4}` is a one-line change if reviewers want
parity.
2. Disabled-attr E2E selector swap (`.p-colorpicker-preview` →
`.color-picker-wrapper > button`) + the eval-helper update that now
handles `HTMLButtonElement` in addition to `HTMLInputElement`. The
structural selector matches what PrimeVue had; happy to add a
`data-testid` if reviewers prefer.
3. `ColorPicker.vue` gained a `disabled` prop — kept explicit (peer of
`class`) to match the existing prop shape rather than forwarding through
`$attrs`.

## Follow-up (NOT in this PR)

Discussed in-thread — the **Reka-UI `ColorField` migration** (full
picker rebuild) and the **Kijai regression suite** for alpha-disabled +
manual-entry on the node-canvas COLOR widget belong in a separate,
scoped PR alongside the `ColorInputSpec` schema additions (`hasAlpha`,
`format`). The custom picker also has a known lossy HSV-percent
quantization (e.g. `#1133aa` round-trips to `#1033a9`) that pre-dates
this PR and would be addressed by the Reka primitives.

- Fixes FE-804


## Screenshots

![Migrated FormColorPicker: orange swatch with hex label next to a text
input showing legacy no-# value, plus a disabled row visibly
muted](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/83c8829f71d395971cd24bb8b016314e129aa2c3a1e431eec372e88bbce09aa5/pr-images/1779319155370-5029e334-00fb-4610-8aae-7d7436069473.png)

![Color picker popover open: saturation/value panel, hue slider, alpha
slider, hex/rgba dropdown, hosted by Reka-UI
Popover](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/83c8829f71d395971cd24bb8b016314e129aa2c3a1e431eec372e88bbce09aa5/pr-images/1779319155802-900f903d-0e4b-4a9a-9d23-da7dd0e02358.png)

![Partial hex entry: text input shows '#ab' while the picker swatch
remains orange - no clobber while
typing](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/83c8829f71d395971cd24bb8b016314e129aa2c3a1e431eec372e88bbce09aa5/pr-images/1779319156247-cc507939-6102-45e5-ba13-1c16fbe1fb53.png)

![After blur the text input committed and the swatch turned blue; value
stored in legacy no-#
format](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/83c8829f71d395971cd24bb8b016314e129aa2c3a1e431eec372e88bbce09aa5/pr-images/1779319156568-f758dbdc-62fa-41dd-a5fc-fa503360c332.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-03 20:51:23 +00:00
AustinMroz
f0034b9b1b Fix audits again, update vitest (#12620)
#12532 caused some regressions in security audits. I've updated `vitest`
resolve a [newly reported
vulnerability](https://github.com/Comfy-Org/ComfyUI_frontend/security/dependabot/166)
and performed and re-resovled the lockfile to clear out the audit
warnings.

Sanity check: `pnpm audit` passes with the lock-file introduced by this
PR, but multiple packages are showing a downgrade in version. Second
opinions would be appreciated.
2026-06-03 18:38:31 +00:00
nav-tej
e4d5824813 feat(telemetry): capture Rewardful referral on checkout attribution (#12311)
*PR Created by the Glary-Bot Agent*

---

## Summary

Mirrors the existing Impact affiliate wiring for the new Rewardful
affiliate network. The client reads `window.Rewardful.referral` when
`getCheckoutAttribution()` runs at checkout time and emits it as a new
optional `rewardful_referral` field on `CheckoutAttributionMetadata`.
The Go backend consumes this field separately and passes it to Stripe as
`ClientReferenceID` on the Checkout Session create call — that wiring
lives in a sibling PR on `Comfy-Org/cloud` (services/comfy-api) and is
the path that actually credits affiliate commissions for Stripe
subscriptions.

Per Rewardful's docs for server-side Stripe Checkout Sessions, the
GTM-loaded Rewardful JS handles cookie persistence on the client but
**cannot** attribute Checkout Sessions on its own — the merchant must
explicitly pass the referral UUID server-side as `client_reference_id`.

## Why this is the simplest possible client-side change

- Rewardful's JS (loaded via GTM) owns its own cookie persistence, so
unlike Impact (where we capture `im_ref` from URL params and persist to
localStorage ourselves), we just read `window.Rewardful.referral` at
checkout time. No URL fallback, no localStorage handling.
- If Rewardful's script hasn't loaded or the user didn't come from an
affiliate link, the field is simply omitted from the payload.
- Adds a narrow `RewardfulGlobal` interface to `global.d.ts` (`referral`
plus optional `affiliate`/`campaign` metadata Rewardful exposes) so
`window.Rewardful` is typed everywhere it's accessed.
- 4 new unit tests covering: present, absent, empty-string, and
alongside Impact attribution. The existing 10 Impact/UTM tests are
untouched.

## Files touched

| File | Change |
|---|---|
| `global.d.ts` | Add `RewardfulGlobal` interface + `Rewardful?:` on
`Window` |
| `src/platform/telemetry/types.ts` | Add `rewardful_referral?: string`
to `CheckoutAttributionMetadata` |
| `src/platform/telemetry/utils/checkoutAttribution.ts` | Read
`window.Rewardful?.referral` in `getCheckoutAttribution()` |
| `src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts` |
4 new tests + `window.Rewardful = undefined` reset in `beforeEach` |

## Cross-PR dependency

Needs the sibling [`Comfy-Org/cloud`
PR](https://github.com/Comfy-Org/cloud/pulls?q=is%3Apr+rewardful)
(branch `glary/rewardful-affiliate-tracking`) to actually credit
referrals. **This PR is safe to ship independently** — the field is just
ignored by the existing comfy-api endpoint until that PR lands.

## Verification

- `pnpm typecheck` — clean
- `pnpm test:unit src/platform/telemetry/utils` — 14/14 passing (10
prior + 4 new)
- `pnpm test:unit` (full repo) — passing
- `pnpm lint` — 3 warnings, 0 errors (warnings pre-existing on `main`)
- `pnpm format:check` — clean
- `pnpm knip` — clean (1 pre-existing unrelated warning)
- `pnpm exec vite build` — successful (7.85s)

Related: FE-704 (Finish affiliate pages PRs) for context on the broader
affiliate launch.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12311-feat-telemetry-capture-Rewardful-referral-on-checkout-attribution-3626d73d365081beb03afb2e000c83a6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-03 17:40:28 +00:00
271 changed files with 17947 additions and 3976 deletions

View File

@@ -5,6 +5,8 @@ on:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
types:
- completed
branches-ignore:
- main
permissions:
contents: read

View File

@@ -4,6 +4,10 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -38,7 +42,7 @@ test.describe('Download page @smoke', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
@@ -52,6 +56,61 @@ test.describe('Download page @smoke', () => {
await context.close()
})
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
browser
}) => {
const context = await browser.newContext({ userAgent: LINUX_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
const macBtn = hero.locator(
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
)
await expect(macBtn).toBeVisible()
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toHaveCount(2)
await context.close()
})
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
const context = await browser.newContext({ userAgent: IPHONE_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toBeHidden()
await expect(
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
).toBeVisible()
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
@@ -176,7 +235,7 @@ test.describe('Download page mobile @mobile', () => {
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()

View File

@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Local' })
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')

View File

@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Local',
'Comfy Desktop',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { BadgeVariants } from './badge.variants'
import { badgeVariants } from './badge.variants'
const { variant, class: className } = defineProps<{
variant?: BadgeVariants['variant']
class?: string
}>()
</script>
<template>
<span :class="cn(badgeVariants({ variant }), className)">
<slot />
</span>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
const {
locale = 'en',
headingKey,
primaryLabelKey,
primaryHref,
secondaryLabelKey,
secondaryHref
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
primaryLabelKey: TranslationKey
primaryHref?: string
secondaryLabelKey?: TranslationKey
secondaryHref?: string
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<BrandButton
:href="primaryHref"
variant="solid"
size="xs"
class="uppercase"
>
{{ t(primaryLabelKey, locale) }}
</BrandButton>
<BrandButton
v-if="secondaryLabelKey"
:href="secondaryHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(secondaryLabelKey, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
descriptionKey,
notifyLabelKey,
notifyHref,
events
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
descriptionKey: TranslationKey
notifyLabelKey: TranslationKey
notifyHref?: string
events: readonly EventItem[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-12">
<div
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
>
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
<div>
<BrandButton
:href="notifyHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(notifyLabelKey, locale) }}
</BrandButton>
</div>
</div>
<div class="flex flex-col">
<a
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</a>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,165 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MaskRevealButton from './MaskRevealButton.vue'
const meta: Meta<typeof MaskRevealButton> = {
title: 'Website/Common/MaskRevealButton',
component: MaskRevealButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { href: '#' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `
<MaskRevealButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
`
})
}
export const LabelVisible: Story = {
args: { href: '#', hideLabel: false },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template:
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { MaskRevealButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
</div>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
import {
maskRevealButtonBadgeVariants,
maskRevealButtonVariants,
maskRevealLabelVariants
} from './maskRevealButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = true,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: MaskRevealButtonVariants['variant']
size?: MaskRevealButtonVariants['size']
iconPosition?: MaskRevealButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:data-icon-position="iconPosition ?? 'right'"
:data-hidden="hideLabel ? 'true' : 'false'"
:class="maskRevealLabelVariants()"
>
<slot />
</span>
<span
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -0,0 +1,165 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PillButton from './PillButton.vue'
const meta: Meta<typeof PillButton> = {
title: 'Website/Common/PillButton',
component: PillButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AsAnchor: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const AsButton: Story = {
args: { type: 'button' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Submit</PillButton>'
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Read More</PillButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `
<PillButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</PillButton>
`
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Go Back</PillButton>'
})
}
export const RevealLabelOnHover: Story = {
args: { href: '#', hideLabel: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Unavailable</PillButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { PillButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
</div>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { PillButtonVariants } from './pillButton.variants'
import {
pillButtonBadgeVariants,
pillButtonVariants
} from './pillButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = false,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: PillButtonVariants['variant']
size?: PillButtonVariants['size']
iconPosition?: PillButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:class="
cn(
'relative leading-none transition-all duration-500',
hideLabel && 'opacity-0 group-hover:opacity-100'
)
"
>
<slot />
</span>
<span
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -2,7 +2,7 @@
const {
logoSrc = '/icons/logo.svg',
logoAlt = 'Comfy',
text = 'LOCAL'
text = 'DESKTOP'
} = defineProps<{
logoSrc?: string
logoAlt?: string
@@ -20,7 +20,7 @@ const {
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
>
<img
:src="logoSrc"
@@ -37,7 +37,7 @@ const {
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
>
<span
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"

View File

@@ -43,6 +43,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('footer.blog', locale),
href: externalLinks.blog,

View File

@@ -52,6 +52,7 @@ const navLinks: NavLink[] = [
{
label: t('nav.resources', locale),
items: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
@@ -93,7 +94,7 @@ const ctaButtons = [
{
label: t('nav.downloadLocal', locale),
prefix: 'DOWNLOAD',
core: 'LOCAL',
core: 'DESKTOP',
href: routes.download,
primary: false
},
@@ -163,7 +164,7 @@ onMounted(() => {
/>
<nav
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -0,0 +1,17 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
}
},
defaultVariants: {
variant: 'default'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -0,0 +1,110 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const maskRevealButtonVariants = cva({
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealLabelVariants = cva({
base: [
'relative inline-block align-baseline',
'[will-change:mask-size,-webkit-mask-size]',
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
].join(' ')
})
export type MaskRevealButtonVariants = VariantProps<
typeof maskRevealButtonVariants
>

View File

@@ -0,0 +1,116 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const pillButtonVariants = cva({
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
},
{
size: 'md',
iconPosition: 'right',
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
},
{
size: 'lg',
iconPosition: 'right',
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
},
{
size: 'sm',
iconPosition: 'left',
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
},
{
size: 'md',
iconPosition: 'left',
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
},
{
size: 'lg',
iconPosition: 'left',
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const pillButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const tags = ['Seadance 2.0', 'Image To Video']
const demoVideoSrc =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
const demoVideoPoster =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<div>
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
{{ t('learning.featured.author', locale) }}
</p>
</div>
<p
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>
<div class="flex flex-wrap gap-3">
<BrandButton
variant="outline"
size="xs"
class="uppercase"
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
>
{{ t('cta.tryWorkflow', locale) }}
</BrandButton>
</div>
<ul class="mt-2 flex flex-wrap gap-3">
<li v-for="tag in tags" :key="tag">
<Badge variant="subtle">{{ tag }}</Badge>
</li>
</ul>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<VideoPlayer
:locale
:src="demoVideoSrc"
:poster="demoVideoPoster"
minimal
/>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('learning.heroTitle.after', locale) }}
<br />
{{ t('learning.heroTitle.line2', locale) }}
</h1>
</section>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const playFromStart = () => {
const video = videoRef.value
if (!video) return
video.currentTime = 0
void video.play().catch(() => {})
}
watch(
() => tutorial.id,
() => {
playFromStart()
}
)
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@close="emit('close')"
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
>
<video
ref="videoRef"
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
controls
autoplay
playsinline
></video>
</div>
<h2
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
<ul
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in learningTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="sm"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -3,7 +3,10 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import {
downloadUrls,
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
@@ -12,32 +15,64 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { downloadUrl, platform } = useDownloadUrl()
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const iconSrc = computed(() => {
switch (platform.value) {
case 'mac':
return '/icons/os/apple.svg'
case 'windows':
return '/icons/os/windows.svg'
default:
return undefined
const ICONS = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
} as const
interface ButtonSpec {
key: string
href: string
icon: string
ariaLabel?: string
}
const buttons = computed<ButtonSpec[]>(() => {
if (platform.value) {
return [
{
key: platform.value,
href: downloadUrl.value,
icon: ICONS[platform.value]
}
]
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label} — macOS`
}
]
}
return []
})
</script>
<template>
<BrandButton
v-show="platform"
:href="downloadUrl"
v-for="btn in buttons"
:key="btn.key"
:href="btn.href"
target="_blank"
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
>
<span class="inline-flex items-center gap-2">
<img
v-if="iconSrc"
:src="iconSrc"
:src="btn.icon"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"

View File

@@ -283,16 +283,16 @@ onUnmounted(() => {
<div
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
>
<ProductHeroBadge />
<ProductHeroBadge text="DESKTOP" />
<h1
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
>
{{ t('download.hero.heading', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
>
{{ t('download.hero.subtitle', locale) }}
</p>

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
const downloadUrls = {
export const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
@@ -24,6 +24,8 @@ function detectPlatform(ua: string): DetectedPlatform {
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const detected = ref(false)
const isMobileUa = ref(false)
const downloadUrl = computed(() => {
if (platform.value === 'windows') return downloadUrls.windows
@@ -31,9 +33,16 @@ export function useDownloadUrl() {
return externalLinks.github
})
const showFallback = computed(
() => detected.value && !platform.value && !isMobileUa.value
)
onMounted(() => {
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
const ua = navigator.userAgent.toLowerCase()
isMobileUa.value = isMobile(ua)
platform.value = detectPlatform(ua)
detected.value = true
})
return { downloadUrl, platform }
return { downloadUrl, platform, showFallback }
}

View File

@@ -12,6 +12,7 @@ const baseRoutes = {
careers: '/careers',
customers: '/customers',
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',

View File

@@ -0,0 +1,31 @@
import type { EventItem } from '../components/common/EventsSection.vue'
export const learningEvents: readonly EventItem[] = [
{
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
title: {
en: 'Zero to Node: Building Your First Workflow',
'zh-CN': '从零到节点:构建你的第一个工作流'
},
cta: { en: 'Link', 'zh-CN': '链接' },
href: '#'
},
{
label: { en: 'Event 1', 'zh-CN': '活动 1' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
href: '#'
},
{
label: { en: 'Event 2', 'zh-CN': '活动 2' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
href: '#'
}
] as const

View File

@@ -0,0 +1,84 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1,6 +1,22 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// Tags (global, reusable across sections)
'tags.partnerNodes': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
},
'tags.imageToVideo': {
en: 'Image To Video',
'zh-CN': '图像生成视频'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
@@ -108,8 +124,8 @@ const translations = {
'zh-CN': '下载或注册'
},
'getStarted.step1.downloadLocal': {
en: 'Download Local',
'zh-CN': '下载本地版'
en: 'Download Desktop',
'zh-CN': '下载桌面版'
},
'getStarted.step1.launchCloud': {
en: 'Launch Cloud',
@@ -589,8 +605,8 @@ const translations = {
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
},
'download.faq.4.q': {
en: 'Why would I pay for Comfy Cloud if Local is free?',
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud'
en: 'Why would I pay for Comfy Cloud if Desktop is free?',
'zh-CN': '既然桌面版免费,为什么还要付费使用 Comfy Cloud'
},
'download.faq.4.a': {
en: 'Your machine or ours. Cloud gives you powerful GPUs on demand, pre-loaded models, end-to-end security and infrastructure out of the box and partner models cleared for commercial use.',
@@ -607,8 +623,8 @@ const translations = {
'Desktop一键安装自动更新。Portable独立构建可从任意文件夹运行。CLI从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
},
'download.faq.6.q': {
en: 'Can I use my local workflows in Comfy Cloud?',
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
en: 'Can I use my Desktop workflows in Comfy Cloud?',
'zh-CN': '我可以在 Comfy Cloud 中使用桌面工作流吗?'
},
'download.faq.6.a': {
en: 'Yes — same file, same results. No conversion, no rework.',
@@ -649,8 +665,8 @@ const translations = {
'zh-CN': '专业人士为何\n选择'
},
'download.reason.headingHighlight': {
en: 'Local',
'zh-CN': '本地版'
en: 'Desktop',
'zh-CN': '桌面版'
},
'download.reason.1.title': {
en: 'Unlimited\nCustomization',
@@ -699,8 +715,8 @@ const translations = {
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
},
'download.hero.downloadLocal': {
en: 'DOWNLOAD LOCAL',
'zh-CN': '下载本地版'
en: 'DOWNLOAD DESKTOP',
'zh-CN': '下载桌面版'
},
'download.hero.installGithub': {
en: 'INSTALL FROM GITHUB',
@@ -1435,6 +1451,62 @@ const translations = {
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
// LearningHeroSection
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
'learning.heroTitle.line2': {
en: 'Build what doesnt exist yet.',
'zh-CN': '构建尚未存在之物。'
},
// LearningFeaturedWorkflowSection
'learning.featured.title': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'learning.featured.author': {
en: 'by Doug Hogan',
'zh-CN': '作者Doug Hogan'
},
'learning.featured.description': {
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
'zh-CN':
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
},
'learning.featured.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// LearningTutorialsSection
'learning.tutorials.heading': {
en: 'Featured Demos',
'zh-CN': '精选演示'
},
'learning.tutorials.titlePrefix': {
en: 'Learn how to:',
'zh-CN': '学习如何:'
},
// LearningCallToActionSection
'learning.cta.heading': {
en: 'Schedule a demo and see how ComfyUI fits your teams creative needs.',
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
},
'learning.cta.contactSales': {
en: 'Contact Sales',
'zh-CN': '联系销售'
},
// LearningEventsSection
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
'learning.events.description': {
en: 'Check out our upcoming live streams and community meetings. Were always open to your questions, ideas, and conversations.',
'zh-CN':
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
},
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
// GalleryHeroSection
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
'gallery.heroTitle.before': {
@@ -1471,9 +1543,13 @@ const translations = {
},
'about.hero.body': {
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
'zh-CN':
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
},
'about.hero.cta': {
en: 'SEE OPEN ROLES',
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
},
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
// AboutStorySection
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
@@ -1734,7 +1810,7 @@ const translations = {
'nav.community': { en: 'Community', 'zh-CN': '社区' },
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
'nav.company': { en: 'Company', 'zh-CN': '公司' },
'nav.comfyLocal': { en: 'Comfy Local', 'zh-CN': 'Comfy 本地版' },
'nav.comfyLocal': { en: 'Comfy Desktop', 'zh-CN': 'Comfy 桌面版' },
'nav.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
'nav.comfyApi': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
'nav.comfyEnterprise': {
@@ -1743,6 +1819,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
@@ -1751,7 +1828,7 @@ const translations = {
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.downloadLocal': { en: 'DOWNLOAD LOCAL', 'zh-CN': '下载本地版' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
@@ -1802,8 +1879,9 @@ const translations = {
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
},
'privacy.intro.block.3': {
en: 'This policy is effective as of April 18, 2025.',
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
'zh-CN':
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop本地安装应用程序的具体信息包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
},
'privacy.information-we-collect.label': {
en: 'INFORMATION',
@@ -2053,6 +2131,181 @@ const translations = {
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
},
// ── Desktop Privacy Policy ────────────────────────────────────────
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
'desktop_privacy.intro.block.0': {
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
},
'desktop_privacy.intro.block.1': {
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
'zh-CN':
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
},
'desktop_privacy.controller.label': {
en: 'CONTROLLER',
'zh-CN': 'CONTROLLER'
},
'desktop_privacy.controller.title': {
en: 'Controller',
'zh-CN': 'Controller'
},
'desktop_privacy.controller.block.0': {
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
'desktop_privacy.data.label': {
en: 'DATA WE PROCESS',
'zh-CN': 'DATA WE PROCESS'
},
'desktop_privacy.data.title': {
en: 'Personal data we process',
'zh-CN': 'Personal data we process'
},
'desktop_privacy.data.block.0': {
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
'zh-CN':
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
},
'desktop_privacy.data.block.1': {
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
'zh-CN':
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
},
'desktop_privacy.data.block.2': {
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
'zh-CN':
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
},
'desktop_privacy.data.block.3': {
en: 'We do not process:',
'zh-CN': 'We do not process:'
},
'desktop_privacy.data.block.4': {
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
'zh-CN':
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
},
'desktop_privacy.data.block.5': {
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
'zh-CN':
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
},
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
'desktop_privacy.purposes.title': {
en: 'Purposes and lawful bases',
'zh-CN': 'Purposes and lawful bases'
},
'desktop_privacy.purposes.block.0': {
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
'zh-CN':
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
},
'desktop_privacy.purposes.block.1': {
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
'zh-CN':
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
},
'desktop_privacy.purposes.block.2': {
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
'desktop_privacy.purposes.block.3': {
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
'zh-CN':
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
},
'desktop_privacy.processors.label': {
en: 'RECIPIENTS',
'zh-CN': 'RECIPIENTS'
},
'desktop_privacy.processors.title': {
en: 'Recipients',
'zh-CN': 'Recipients'
},
'desktop_privacy.processors.block.0': {
en: 'We engage the following processors under Data Processing Agreements:',
'zh-CN':
'We engage the following processors under Data Processing Agreements:'
},
'desktop_privacy.processors.block.1': {
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
'zh-CN':
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
},
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
'desktop_privacy.transfers.title': {
en: 'International transfers',
'zh-CN': 'International transfers'
},
'desktop_privacy.transfers.block.0': {
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
'zh-CN':
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
},
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
'desktop_privacy.retention.block.0': {
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
'zh-CN':
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
},
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
'desktop_privacy.rights.block.0': {
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
'zh-CN':
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
},
'desktop_privacy.rights.block.1': {
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
'zh-CN':
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
},
'desktop_privacy.rights.block.2': {
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
'zh-CN':
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
},
'desktop_privacy.rights.block.3': {
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
'zh-CN':
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
},
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
'desktop_privacy.children.block.0': {
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
'zh-CN':
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
},
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
'desktop_privacy.changes.block.0': {
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
'zh-CN':
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
},
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
'desktop_privacy.contact.block.0': {
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
// ── Terms of Service ──────────────────────────────────────────────
'tos.effectiveDateLabel': {
en: 'Effective Date',
@@ -4736,6 +4989,8 @@ const translations = {
type TranslationKey = keyof typeof translations
type LocalizedText = Record<Locale, string>
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
@@ -4746,4 +5001,4 @@ export function hasKey(key: string): boolean {
return key in translations
}
export type { Locale, TranslationKey }
export type { Locale, LocalizedText, TranslationKey }

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../components/learning/TutorialsSection.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
// import EventsSection from '../components/common/EventsSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
const routes = getRoutes('en')
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -0,0 +1,13 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ContentSection from '../../components/common/ContentSection.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy — Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />
<ContentSection prefix="desktop_privacy" client:load />
</BaseLayout>

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -0,0 +1,13 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ContentSection from '../../../components/common/ContentSection.vue'
import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 — Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
</BaseLayout>

View File

@@ -0,0 +1,115 @@
{
"id": "test-missing-model-promoted-widget",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,48 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -100,10 +100,7 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
await this.nextFrame()
}
async resizeByDragging(
element: Locator,
{ x, y }: { x?: number; y?: number }
) {
async dragElementBy(element: Locator, { x, y }: { x?: number; y?: number }) {
const elementBox = await element.boundingBox()
if (!elementBox) throw new Error('element should have layout')

View File

@@ -1,10 +1,11 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],

View File

@@ -45,6 +45,8 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
@@ -54,6 +56,7 @@ export const TestIds = {
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
errorGroupDisplayMessage: 'error-group-display-message',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
@@ -68,11 +71,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
in_library: false
}
const defaultInputAsset: Asset = {
const defaultInputAsset: Asset & { hash?: string } = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
const importedInputAsset: Asset & { hash?: string } = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}
export async function intersection(a: Locator, b: Locator) {
const aBounds = await a.boundingBox()
const bBounds = await b.boundingBox()
if (!aBounds || !bBounds) return undefined
const y = Math.max(aBounds.y, bBounds.y)
const x = Math.max(aBounds.x, bBounds.x)
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
if (y > bot || x > right) return undefined
const width = right - x
const height = bot - y
return { x, y, width, height }
}

View File

@@ -19,63 +19,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
return page.getByTestId(TestIds.dialogs.errorOverlay)
}
function getSeeErrorsButton(page: Page) {
function getDetailsButton(page: Page) {
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
}
test.describe('Labels', () => {
test('Should display singular error count label for single error', async ({
test('Should display single error copy and View details action', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
})
test('Should display "Show missing nodes" button for missing node errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing nodes/i
)
})
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
await cleanupFakeModel(comfyPage)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing models/i
)
})
test('Should display "Show missing inputs" button for missing media errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing inputs/i
)
})
test('Should display generic "See Errors" button for multiple error types', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/See Errors/i
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).not.toContainText(/1 ERROR/i)
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toHaveText(/\S/)
await expect(getDetailsButton(comfyPage.page)).toContainText(
/View details/i
)
})
})
@@ -137,7 +98,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
})
})
test.describe('See Errors flow', () => {
test.describe('View details flow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
@@ -166,7 +127,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
test('"View details" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
@@ -178,7 +139,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
test('"View details" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
@@ -229,19 +190,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
}) => {
// Regression: ErrorOverlay previously read the selection-filtered
// missingModelGroups from useErrorGroups, so selecting one of two
// missing-model nodes would shrink the overlay label from
// "2 required models are missing" to "1". The overlay must show
// the workflow total regardless of canvas selection.
// missing-model nodes could shrink the overlay count. The overlay must
// show the workflow total regardless of canvas selection.
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toContainText(/2 required models are missing/i)
await expect(overlay).toContainText(/2 errors found/i)
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toHaveText(/Resolve them before running the workflow\./i)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await expect(overlay).toContainText(/2 required models are missing/i)
await expect(overlay).toContainText(/2 errors found/i)
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toHaveText(/Resolve them before running the workflow\./i)
})
})
})

View File

@@ -208,7 +208,7 @@ test.describe('Topbar commands', () => {
type: 'color',
defaultValue: '#000000'
},
selector: '.p-colorpicker-preview'
selector: '.color-picker-wrapper > button'
}
] as const
@@ -239,8 +239,9 @@ test.describe('Topbar commands', () => {
await expect
.poll(() =>
component.evaluate((el) =>
el.tagName === 'INPUT'
? (el as HTMLInputElement).disabled
el instanceof HTMLInputElement ||
el instanceof HTMLButtonElement
? el.disabled
: el.classList.contains('p-disabled')
)
)

View File

@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
await expect(expansionContent).toBeHidden()
})
})
test.describe('Responsive Layout', () => {
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await page.setViewportSize({ width: 480, height: 800 })
await expect(
row.getByRole('button', { name: /Delete/i })
).toBeInViewport()
await expect(
row.getByRole('button', { name: /Add new keybinding/i })
).toBeInViewport()
const hasHorizontalScroll = await page
.locator('.keybinding-panel .p-datatable-table-container')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
expect(hasHorizontalScroll).toBe(false)
})
test('Keybinding column compresses with width while actions stay reachable', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const keybindingList = row.getByTestId('keybinding-list')
await expect(keybindingList).toBeVisible()
const listWidthAt = async (viewportWidth: number) => {
await page.setViewportSize({ width: viewportWidth, height: 800 })
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
}
const wideWidth = await listWidthAt(1280)
const narrowWidth = await listWidthAt(560)
expect(narrowWidth).toBeLessThan(wideWidth)
await expect(
row.getByRole('button', { name: /Delete/i })
).toBeInViewport()
})
})
})

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
type Load3dImageInput = {
image: string
mask: string
normal: string
recording: string
}
type PromptBody = {
prompt?: Record<
string,
{ class_type?: string; inputs?: Record<string, unknown> }
>
}
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
const prompt = (body as PromptBody).prompt ?? {}
const node = prompt[nodeId]
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
const input = node!.inputs!.image as Load3dImageInput
expect(typeof input.image).toBe('string')
expect(typeof input.recording).toBe('string')
return input
}
test.describe('Load3D serialize cache', () => {
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
comfyPage,
load3d
}) => {
const exec = new ExecutionHelper(comfyPage)
let firstBody: unknown
await exec.run({
onPromptRequest: (body) => {
firstBody = body
}
})
const firstInput = getLoad3dImageInput(firstBody, '1')
expect(firstInput.recording).toBe('')
await load3d.recordingButton.click()
await expect(load3d.stopRecordingButton).toBeVisible()
let secondBody: unknown
await exec.run({
onPromptRequest: (body) => {
secondBody = body
}
})
const secondInput = getLoad3dImageInput(secondBody, '1')
expect(
secondInput.image,
'after starting a recording, the next queue must re-capture ' +
'(image filename must change) so the recording is not dropped'
).not.toBe(firstInput.image)
})
})

View File

@@ -11,6 +11,7 @@ import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
@@ -38,6 +39,9 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await comfyPage.setup()
})
test('Should filter execution errors by search query', async ({
test('Should keep execution errors matching the search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await searchInput.fill('Execution failed')
await expect(runtimePanel).toHaveCount(0)
await expect(runtimePanel).toBeVisible()
})
})
})

View File

@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
).toBeVisible()
})
test('Should show error message in runtime error panel', async ({
test('Should show runtime error log in the execution error group', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText(/\S/)
await expect(runtimePanel).toContainText('Error log')
})
})

View File

@@ -5,35 +5,12 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
@@ -46,14 +23,24 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const overlay = getErrorOverlay(comfyPage)
await expect(overlay).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toContainText(/Load Image/)
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
await expect(missingMediaGroup).toBeVisible()
await expect(
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Shows correct number of missing media rows', async ({
@@ -67,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -75,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -108,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})

View File

@@ -23,11 +23,31 @@ const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
widgetName: 'image',
serverOnlyOption: 'server-only-image.png',
position: { x: 150, y: 150 }
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
serverOnlyOption: 'server-only-video.mp4',
position: { x: 450, y: 150 }
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
serverOnlyOption: 'server-only-audio.wav',
position: { x: 750, y: 150 }
}
]
const cloudOutputAsset: Asset = {
const cloudOutputAsset: Asset & { hash?: string } = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +56,10 @@ const cloudOutputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -66,12 +86,168 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/api/userdata**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
})
await page.route('**/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/customers/cloud-subscription-status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ is_active: true })
})
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
})
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
@@ -106,9 +282,13 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
try {
await use(page)
} finally {
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await unrouteObjectInfo()
cloudUploadAssetStateByPage.delete(page)
}
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
@@ -139,7 +319,41 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
).toBeHidden()
}
async function delayNextUpload(comfyPage: ComfyPage) {
async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) {
const templatesDialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.templates.content
})
const closeButton = templatesDialog.getByRole('button', {
name: 'Close dialog'
})
await closeButton
.waitFor({ state: 'visible', timeout: 1_000 })
.catch(() => undefined)
if (await closeButton.isVisible()) {
await closeButton.click()
await expect(templatesDialog).toBeHidden()
}
}
async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodes) => {
return nodes.map(({ nodeType, widgetName }) => {
const node = window.app!.graph.nodes.find(
(graphNode) => graphNode.type === nodeType
)
const widget = node?.widgets?.find(
(candidate) => candidate.name === widgetName
)
return widget?.value ?? null
})
}, emptyMediaLoaderNodes)
}
async function delayNextUpload(
comfyPage: ComfyPage,
uploadResult?: { name: string; subfolder: string; type: 'input' }
) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
@@ -152,6 +366,14 @@ async function delayNextUpload(comfyPage: ComfyPage) {
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
if (uploadResult) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(uploadResult)
})
return
}
await route.continue()
}
@@ -295,12 +517,51 @@ ossTest.describe(
}
)
cloudEmptyMediaInputsTest.describe(
'Errors tab - Cloud empty media loader inputs',
{ tag: '@cloud' },
() => {
cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudEmptyMediaInputsTest(
'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
for (const node of emptyMediaLoaderNodes) {
await comfyPage.nodeOps.addNode(
node.nodeType,
undefined,
node.position
)
}
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'input')
)
)
.toBe(true)
await expect
.poll(() => getMediaLoaderWidgetValues(comfyPage))
.toEqual(['', '', ''])
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudOutputTest(
@@ -329,13 +590,18 @@ cloudUploadRaceTest.describe(
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
const delayedUpload = await delayNextUpload(comfyPage, {
name: plainVideoFileName,
subfolder: '',
type: 'input'
})
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition

View File

@@ -25,9 +25,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeVisible()
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,93 +12,97 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should show missing node packs group', async ({ comfyPage }) => {
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should expand pack group to reveal node type names', async ({
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Should toggle grouped pack nodes from chevron and title', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
'missing/missing_nodes_same_pack'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
})
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
const expandButton = missingNodeCard
.getByTestId(TestIds.dialogs.missingNodePackExpand)
.first()
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
})
})

View File

@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
await expect(missingModelGroup).toBeHidden()
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
/**
* Expanded folder view must drop output records that resolve to the same
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
* v-for in VirtualGrid collides and one asset visibly duplicates its
* neighbours while scrolling.
*/
const STACK_JOB_ID = 'job-output-dedupe'
const COVER_NODE_ID = '9'
const COVER_FILENAME = 'cover_00001_.png'
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
// 4 unique composite keys expected after dedupe.
const STACK_JOB_OUTPUTS = [
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
...DISTINCT_FILENAMES.map((filename) => ({
filename,
subfolder: '',
type: 'output' as const
})),
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
]
const STACK_JOB = createMockJob({
id: STACK_JOB_ID,
create_time: 5000,
execution_start_time: 5000,
execution_end_time: 5050,
preview_output: {
filename: COVER_FILENAME,
subfolder: '',
type: 'output',
nodeId: COVER_NODE_ID,
mediaType: 'images'
},
outputs_count: STACK_JOB_OUTPUTS.length
})
const STACK_JOB_DETAIL: JobDetail = {
...STACK_JOB,
outputs: {
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
}
}
const EXPECTED_TOTAL_TILES = 4
test.describe(
'Expanded folder view dedupes duplicate composite output keys',
{ tag: '@cloud' },
() => {
// @cloud comfyPage already navigates with Firebase auth seeded; a second
// setup() call would clear localStorage and bounce to /cloud/login.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([STACK_JOB])
await comfyPage.assets.mockInputFiles([])
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('renders one tile per unique composite key', async ({
comfyPage
}, testInfo) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
const labels = await tab.assetCards.evaluateAll((nodes) =>
nodes
.map((el) => el.getAttribute('aria-label'))
.filter((v): v is string => v !== null)
)
expect(new Set(labels).size).toBe(labels.length)
await testInfo.attach('expanded-folder-view.png', {
body: await comfyPage.page.screenshot({ fullPage: false }),
contentType: 'image/png'
})
})
}
)

View File

@@ -289,17 +289,17 @@ test.describe('Node library sidebar', () => {
await customColorOption.click()
await customColorOption.click()
// Use the color picker
await comfyPage.page
.getByLabel('Customize Folder')
.getByRole('textbox')
.click()
await comfyPage.page.locator('.p-colorpicker-color-background').click()
// Finalize the customization
const dialog = comfyPage.page.getByRole('dialog', {
name: 'Customize Folder'
})
await dialog
.locator('.color-customization-selector-container > button')
.last()
.click()
await comfyPage.page
.getByLabel('Color saturation and brightness')
.click({ position: { x: 10, y: 10 } })
// Select Folder icon (2nd button in Icon group)
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
await iconGroup.getByRole('button').nth(1).click()

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Textarea widget font size',
{ tag: ['@widget', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
})
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
comfyPage
}) => {
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
await expect(textarea).toBeVisible()
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('14px')
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('22px')
})
}
)

View File

@@ -8,6 +8,7 @@ import {
} from '@e2e/fixtures/ComfyPage'
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
@@ -1231,3 +1232,41 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
}).toPass({ timeout: 5000 })
})
})
test(
'Fast disconnection support',
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
await comfyMouse.click(100, 100)
}
const isConnected = () => comfyPage.vueNodes.isSlotConnected(slot)
await expect.poll(isConnected).toBe(false)
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
}
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)
await performDisconnect(ksampler.getSlot('positive'), false)
})
await test.step('Create subgraph', async () => {
await ksampler.title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await comfyPage.vueNodes.enterSubgraph()
})
await test.step('Disconnection with subgraph IO', async () => {
await performDisconnect(ksampler.getSlot('negative'), true)
await performDisconnect(ksampler.getSlot('latent_image'), false)
})
}
)

View File

@@ -174,10 +174,50 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await comfyMouse.dragElementBy(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await comfyMouse.dragElementBy(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
wstest(
'requests lightweight thumbnail URLs for grid cells',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
const gridImages = node.imageGrid.locator('img')
await test.step('Inject a multi-image grid', async () => {
const images = Array.from({ length: 4 }, (_, index) => ({
filename: `grid-${index}.png`,
subfolder: '',
type: 'output'
}))
execution.executed('', '1', { images })
await expect(gridImages).toHaveCount(4)
})
// FE-741: small on-node grid cells must request a server re-encoded
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
// downloading the full-resolution image, while still pointing at the
// real `/api/view` URL for that output. Verifies the full path: WS
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
// rendered grid `<img>`.
for (const cell of await gridImages.all()) {
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
}
}
)
})

View File

@@ -54,6 +54,35 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
})
}
const advancedButtonOverflowPx = 24
const holdPointCanvasInsetPx = 8
const getAdvancedInputsButton = (node: Locator) =>
node.getByTestId('advanced-inputs-button')
const moveAdvancedButtonRightEdgePastCanvas = async (
comfyPage: ComfyPage,
button: Locator,
overflow: number
) => {
const box = await button.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!box) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const scale = await comfyPage.canvasOps.getScale()
const deltaX = canvasBox.x + canvasBox.width + overflow - box.x - box.width
await comfyPage.page.evaluate(
({ deltaX, scale }) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] += deltaX / scale
canvas.setDirty(true, true)
},
{ deltaX, scale }
)
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -123,7 +152,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const showButton = getAdvancedInputsButton(node)
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
@@ -143,6 +172,83 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(beforePos, afterPos)
})
test(
'should not pan while holding the Advanced button without dragging',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = getAdvancedInputsButton(node)
await expect(showButton).toBeVisible()
await moveAdvancedButtonRightEdgePastCanvas(
comfyPage,
showButton,
advancedButtonOverflowPx
)
const buttonBox = await showButton.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!buttonBox) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const canvasRight = canvasBox.x + canvasBox.width
const buttonRight = buttonBox.x + buttonBox.width
expect(
buttonRight,
'Advanced button should extend past the canvas right edge'
).toBeGreaterThan(canvasRight)
const holdPoint = {
x: canvasRight - holdPointCanvasInsetPx,
y: buttonBox.y + buttonBox.height / 2
}
expect(
holdPoint.x,
'Hold point should stay inside the visible part of the Advanced button'
).toBeGreaterThanOrEqual(buttonBox.x)
expect(
holdPoint.x,
'Hold point should stay inside the visible canvas'
).toBeLessThanOrEqual(canvasRight)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeGreaterThanOrEqual(buttonBox.y)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeLessThanOrEqual(buttonBox.y + buttonBox.height)
const beforeOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(holdPoint.x, holdPoint.y)
await comfyPage.page.mouse.down()
try {
await comfyPage.idleFrames(8)
} finally {
await comfyPage.page.mouse.up()
}
const afterOffset = await comfyPage.canvasOps.getOffset()
expect(afterOffset[0]).toBeCloseTo(beforeOffset[0], 3)
expect(afterOffset[1]).toBeCloseTo(beforeOffset[1], 3)
}
)
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
const FLOAT_SOURCE_POSITION_LEFT_OF_NODE = { x: 100, y: 200 }
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -32,6 +33,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
return getNode(comfyPage).locator('.lg-node-widget')
}
async function getWidgetIndex(comfyPage: ComfyPage, widgetName: string) {
const index = await comfyPage.page.evaluate((name) => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return node?.widgets?.findIndex((widget) => widget.name === name) ?? -1
}, widgetName)
expect(
index,
`${widgetName} widget should exist on ModelSamplingFlux`
).toBeGreaterThanOrEqual(0)
return index
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
@@ -72,6 +87,47 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
})
test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const maxShiftWidget = node.getByLabel('max_shift', { exact: true })
const baseShiftWidget = node.getByLabel('base_shift', { exact: true })
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeVisible()
const primitive = await comfyPage.nodeOps.addNode(
'PrimitiveFloat',
{},
FLOAT_SOURCE_POSITION_LEFT_OF_NODE
)
const [target] =
await comfyPage.nodeOps.getNodeRefsByType('ModelSamplingFlux')
const maxShiftIndex = await getWidgetIndex(comfyPage, 'max_shift')
await primitive.connectWidget(0, target, maxShiftIndex)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return (
node?.inputs.find((input) => input.widget?.name === 'max_shift')
?.link ?? null
)
})
)
.not.toBeNull()
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeHidden()
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {

View File

@@ -1,10 +1,12 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Locator } from '@playwright/test'
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
async function openSamplerDropdown(comfyPage: ComfyPage) {
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
.getByRole('combobox', { name: 'scheduler', exact: true })
await expect(schedulerComboAfterReload).toContainText('karras')
})
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const nodeName = 'Resize Image/Mask'
await comfyPage.searchBoxV2.addNode(nodeName, {
position: { x: 200, y: 630 }
})
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
await node.select()
await expect(comfyPage.selectionToolbox).toBeVisible()
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
await combo.click()
const dropdown = comfyPage.page.getByTestId(
TestIds.widgets.selectDefaultViewport
)
await expect(dropdown).toBeVisible()
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
const cX = bounds.x + bounds.width / 2
const cY = bounds.y + bounds.height / 2
const dropdownBounds = (await dropdown.boundingBox())!
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
await dropdown.click({ position, trial: true })
})
})

View File

@@ -40,11 +40,11 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
const gutter = comfyPage.page.getByRole('separator')
await expect(gutter).toBeVisible()
await comfyMouse.resizeByDragging(gutter, { x: -200 })
await comfyMouse.dragElementBy(gutter, { x: -200 })
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
const intermediateWidth = await getWidth()
await comfyMouse.resizeByDragging(gutter, { x: 100 })
await comfyMouse.dragElementBy(gutter, { x: 100 })
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
})
})

14
global.d.ts vendored
View File

@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
interface RewardfulQueueFunction {
(method: 'ready', callback: () => void): void
(...args: unknown[]): void
q?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -63,6 +75,8 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

@@ -5,9 +5,16 @@
"start_url": "/",
"icons": [
{
"src": "/assets/images/comfy-logo-single.svg",
"sizes": "any",
"type": "image/svg+xml"
"src": "/assets/images/comfy-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/images/comfy-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"display": "standalone"

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.8",
"version": "1.46.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -8,12 +8,12 @@
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"type": "module",
"scripts": {
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.config.mts",
"build:cloud": "cross-env DISTRIBUTION=cloud vite build --config vite.config.mts",
"build:desktop": "pnpm --filter @comfyorg/desktop-ui run build",
"build-storybook": "storybook build",
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && vite build --config vite.config.mts",
"build": "pnpm typecheck && vite build --config vite.config.mts",
"clean": "pnpm dlx rimraf dist dist-ssr coverage playwright-report blob-report test-results node_modules/.vite apps/desktop-ui/dist apps/website/dist",
"clean:all": "pnpm clean && pnpm dlx rimraf node_modules",
"size:collect": "node scripts/size-collect.js",

View File

@@ -121,6 +121,7 @@
--comfy-topbar-height: 2.5rem;
--workflow-tabs-height: 2.375rem;
--comfy-input-bg: #222;
--comfy-textarea-font-size: 10px;
--input-text: #ddd;
--descrip-text: #999;
--drag-text: #ccc;

View File

@@ -28,13 +28,15 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelAssetSeedData,
CancelAssetSeedResponse,
CancelAssetSeedResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
@@ -57,11 +59,14 @@ export type {
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClientOptions,
CreateAssetData,
CreateAssetDownloadData,
CreateAssetDownloadError,
CreateAssetDownloadErrors,
CreateAssetDownloadResponse,
CreateAssetDownloadResponses,
CreateAssetError,
CreateAssetErrors,
CreateAssetExportData,
CreateAssetExportError,
CreateAssetExportErrors,
@@ -72,6 +77,8 @@ export type {
CreateAssetFromHashErrors,
CreateAssetFromHashResponse,
CreateAssetFromHashResponses,
CreateAssetResponse,
CreateAssetResponses,
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
@@ -208,6 +215,8 @@ export type {
ForkWorkflowRequest,
ForkWorkflowResponse,
ForkWorkflowResponses,
FreeMemoryData,
FreeMemoryResponses,
GetAllSettingsData,
GetAllSettingsError,
GetAllSettingsErrors,
@@ -221,6 +230,9 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
GetAssetTagHistogramData,
GetAssetTagHistogramError,
GetAssetTagHistogramErrors,
@@ -259,6 +271,9 @@ export type {
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetEmbeddingsData,
GetEmbeddingsResponse,
GetEmbeddingsResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
@@ -305,6 +320,18 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetI18nData,
GetI18nResponse,
GetI18nResponses,
GetInternalFolderPathsData,
GetInternalFolderPathsResponse,
GetInternalFolderPathsResponses,
GetInternalLogsData,
GetInternalLogsRawData,
GetInternalLogsRawResponse,
GetInternalLogsRawResponses,
GetInternalLogsResponse,
GetInternalLogsResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -356,10 +383,7 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -389,8 +413,26 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceByPathData,
GetOAuthProtectedResourceByPathError,
GetOAuthProtectedResourceByPathErrors,
GetOAuthProtectedResourceByPathResponse,
GetOAuthProtectedResourceByPathResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -427,11 +469,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -447,6 +489,7 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -534,6 +577,11 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InsertDynamicConfigData,
InsertDynamicConfigError,
InsertDynamicConfigErrors,
InsertDynamicConfigResponse,
InsertDynamicConfigResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
@@ -642,6 +690,17 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -663,6 +722,21 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -687,6 +761,9 @@ export type {
PromptInfo,
PromptRequest,
PromptResponse,
PruneAssetsData,
PruneAssetsResponse,
PruneAssetsResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
@@ -732,6 +809,9 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
SeedAssetsResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -751,6 +831,8 @@ export type {
SubscribeResponse,
SubscribeResponse2,
SubscribeResponses,
SubscribeToLogsData,
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
@@ -771,11 +853,6 @@ export type {
UpdateAssetErrors,
UpdateAssetResponse,
UpdateAssetResponses,
UpdateAssetTagsData,
UpdateAssetTagsError,
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
@@ -799,11 +876,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
@@ -821,11 +898,6 @@ export type {
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,
UploadAssetData,
UploadAssetError,
UploadAssetErrors,
UploadAssetResponse,
UploadAssetResponses,
UploadImageData,
UploadImageError,
UploadImageErrors,

File diff suppressed because it is too large Load Diff

View File

@@ -399,13 +399,18 @@ export const zCreateWorkflowVersionRequest = z.object({
})
/**
* Offset/limit-based pagination metadata included in list responses.
* Pagination metadata included in list responses. Supports both legacy
* offset/limit pagination and cursor-based pagination. When cursor-based
* pagination is used, `next_cursor` is the primary pagination token and
* `offset`/`total` may be zero.
*
*/
export const zPaginationInfo = z.object({
offset: z.number().int().gte(0),
limit: z.number().int().gte(1),
total: z.number().int().gte(0),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -879,6 +884,155 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']).optional(),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -940,6 +1094,7 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -960,6 +1115,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -971,6 +1127,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1353,7 +1510,8 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1364,19 +1522,20 @@ export const zAsset = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
is_immutable: z.boolean().optional(),
file_path: z.string().nullish()
})
/**
@@ -1385,7 +1544,8 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1394,14 +1554,17 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
tags: z.array(z.string()).optional(),
mime_type: z.string().optional(),
user_metadata: z.record(z.unknown()).optional(),
updated_at: z.string().datetime()
job_id: z.string().uuid().nullish(),
updated_at: z.string().datetime(),
file_path: z.string().nullish()
})
/**
@@ -1753,21 +1916,6 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
})
/**
* Response returned after successfully queuing a workflow prompt.
*/
@@ -1796,7 +1944,8 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1807,18 +1956,19 @@ export const zAssetWritable = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
is_immutable: z.boolean().optional(),
file_path: z.string().nullish()
})
/**
@@ -1827,7 +1977,8 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1961,21 +2112,6 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2027,6 +2163,7 @@ export const zListJobsData = z.object({
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
sort_by: z.enum(['create_time', 'execution_time']).optional(),
sort_order: z.enum(['asc', 'desc']).optional(),
after: z.string().optional(),
offset: z.number().int().gte(0).optional().default(0),
limit: z.number().int().gte(1).lte(1000).optional().default(100)
})
@@ -2132,9 +2269,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
hash: z.string().optional(),
after: z.string().optional()
})
.optional()
})
@@ -2144,26 +2281,34 @@ export const zListAssetsData = z.object({
*/
export const zListAssetsResponse2 = zListAssetsResponse
export const zUploadAssetData = z.object({
export const zCreateAssetData = z.object({
body: z.object({
url: z.string().url(),
name: z.string(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_id: z.string().uuid().optional()
file: z.string(),
hash: z
.string()
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
.optional(),
tags: z.string().optional(),
id: z.string().uuid().optional(),
preview_id: z.string().uuid().optional(),
name: z.string().optional(),
mime_type: z.string().optional(),
user_metadata: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Asset already exists (returned existing asset)
* Asset already existed for this user (deduplicated by content hash); the
* existing asset is returned with created_new=false.
*
*/
export const zUploadAssetResponse = zAssetCreated
export const zCreateAssetResponse = zAssetCreated
export const zCreateAssetFromHashData = z.object({
body: z.object({
hash: z.string().regex(/^(blake3|sha256):[a-f0-9]{64}$/),
hash: z.string().regex(/^blake3:[a-f0-9]{64}$/),
name: z.string().optional(),
tags: z.array(z.string()).min(1),
mime_type: z.string().optional(),
@@ -2174,7 +2319,9 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference already exists (returned existing)
* Asset reference already existed for this user (deduplicated by content
* hash); the existing asset is returned with created_new=false.
*
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2214,7 +2361,8 @@ export const zCreateAssetExportData = z.object({
naming_strategy: z
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
include_previews: z.boolean().optional().default(false)
}),
path: z.never().optional(),
query: z.never().optional()
@@ -2247,7 +2395,7 @@ export const zDeleteAssetData = z.object({
})
/**
* Asset deleted successfully
* Asset record deleted successfully
*/
export const zDeleteAssetResponse = z.void()
@@ -2312,22 +2460,6 @@ export const zAddAssetTagsData = z.object({
*/
export const zAddAssetTagsResponse = zTagsModificationResponse
export const zUpdateAssetTagsData = z.object({
body: z.object({
add: z.array(z.string()).optional(),
remove: z.array(z.string()).optional()
}),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* Tags updated successfully
*/
export const zUpdateAssetTagsResponse = zTagsModificationResponse
export const zListTagsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2509,10 +2641,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByKeyData = z.object({
export const zGetSettingByIdData = z.object({
body: z.never().optional(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2520,14 +2652,14 @@ export const zGetSettingByKeyData = z.object({
/**
* Setting value response
*/
export const zGetSettingByKeyResponse = z.object({
export const zGetSettingByIdResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByKeyData = z.object({
export const zUpdateSettingByIdData = z.object({
body: z.unknown(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2535,7 +2667,7 @@ export const zUpdateSettingByKeyData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByKeyResponse = z.object({
export const zUpdateSettingByIdResponse = z.object({
value: z.unknown().optional()
})
@@ -2691,21 +2823,7 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
type: z.string().optional()
})
export const zGetLogsData = z.object({
@@ -2774,6 +2892,115 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthProtectedResourceByPathData = z.object({
body: z.never().optional(),
path: z.object({
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
}),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceByPathResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3078,6 +3305,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Config inserted successfully
*/
export const zInsertDynamicConfigResponse = z.object({
id: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
message: z.string().optional()
})
export const zSyncApiKeyData = z.object({
body: zSyncApiKeyRequest,
path: z.never().optional(),
@@ -3671,12 +3920,6 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3757,6 +4000,16 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3832,3 +4085,150 @@ export const zGetLegacyViewMetadataData = z.object({
}),
query: z.never().optional()
})
export const zGetEmbeddingsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Embedding names
*/
export const zGetEmbeddingsResponse = z.array(z.string())
export const zFreeMemoryData = z.object({
body: z
.object({
unload_models: z.boolean().optional(),
free_memory: z.boolean().optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetI18nData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Nested map of locale to translation key-value pairs
*/
export const zGetI18nResponse = z.record(z.unknown())
export const zGetInternalFolderPathsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Map of folder type name to list of path entries
*/
export const zGetInternalFolderPathsResponse = z.record(
z.array(z.array(z.string()))
)
export const zGetInternalLogsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Log text
*/
export const zGetInternalLogsResponse = z.string()
export const zGetInternalLogsRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Structured log data
*/
export const zGetInternalLogsRawResponse = z.object({
entries: z
.array(
z.object({
t: z.number().optional(),
m: z.string().optional()
})
)
.optional(),
size: z
.object({
cols: z.number().int().optional(),
rows: z.number().int().optional()
})
.optional()
})
export const zSubscribeToLogsData = z.object({
body: z.object({
clientId: z.string(),
enabled: z.boolean()
}),
path: z.never().optional(),
query: z.never().optional()
})
export const zPruneAssetsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Prune result
*/
export const zPruneAssetsResponse = z.object({
status: z.string().optional(),
marked: z.number().int().optional()
})
export const zSeedAssetsData = z.object({
body: z
.object({
roots: z.array(z.string()).optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Seed started
*/
export const zSeedAssetsResponse = z.object({
status: z.string().optional()
})
export const zGetAssetSeedStatusData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan progress details (files scanned, total, status, etc.)
*/
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
export const zCancelAssetSeedData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan cancelled
*/
export const zCancelAssetSeedResponse = z.object({
status: z.string().optional()
})

1558
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ packages:
- apps/**
- packages/**
nodeOptions: '${NODE_OPTIONS:- } --no-experimental-webstorage --max-old-space-size=8192'
ignoreWorkspaceRootCheck: true
catalogMode: prefer
publicHoistPattern:
@@ -130,7 +132,7 @@ catalog:
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vitest: ^4.1.0
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const resolvedInputs = useResolvedSelectedInputs()

View File

@@ -1,10 +1,10 @@
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
@@ -14,6 +14,12 @@ describe('ColorCustomizationSelector', () => {
{ name: 'Green', value: '#28a745' }
]
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { color: { hex: 'Hex', rgba: 'RGBA' } } }
})
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
@@ -27,8 +33,8 @@ describe('ColorCustomizationSelector', () => {
const result = render(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
plugins: [PrimeVue, i18n],
components: { SelectButton }
},
props: {
modelValue: null,
@@ -68,24 +74,21 @@ describe('ColorCustomizationSelector', () => {
const buttons = getToggleButtons(container)
const customButton = buttons[buttons.length - 1]
expect(customButton).toHaveAttribute('aria-pressed', 'true')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker uses readonly input preview with no ARIA role
const colorPreview = container.querySelector(
'.p-colorpicker-preview'
) as HTMLInputElement | null
expect(colorPreview).not.toBeNull()
})
it('shows color picker when custom option is selected', async () => {
const { container, user } = renderComponent()
const { container, user } = renderComponent({ modelValue: '#0d6efd' })
await nextTick()
const buttons = getToggleButtons(container)
await user.click(buttons[buttons.length - 1])
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container -- count buttons to detect the ColorPicker popover trigger appearing
const initialButtonCount = container.querySelectorAll('button').length
const toggleButtons = getToggleButtons(container)
await user.click(toggleButtons[toggleButtons.length - 1])
await nextTick()
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker internal DOM
container.querySelector('[data-pc-name="colorpicker"]')
).not.toBeNull()
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container -- count buttons to detect the ColorPicker popover trigger appearing
const afterButtonCount = container.querySelectorAll('button').length
expect(afterButtonCount).toBe(initialButtonCount + 1)
})
it('emits update when predefined color is selected', async () => {
@@ -117,7 +120,7 @@ describe('ColorCustomizationSelector', () => {
onUpdate.mockClear()
await user.click(buttons[buttons.length - 1]) // Switch to custom
// When switching to custom, the custom color value inherits from Blue ('0d6efd')
// When switching to custom, the custom color value inherits from Blue
// and the watcher on customColorValue emits the update
expect(onUpdate).toHaveBeenCalledWith('#0d6efd')
})

View File

@@ -30,10 +30,11 @@
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import SelectButton from 'primevue/selectbutton'
import { computed, onMounted, ref, watch } from 'vue'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
const {
modelValue,
colorOptions,
@@ -65,7 +66,7 @@ onMounted(() => {
selectedColorOption.value = predefinedColor
} else {
selectedColorOption.value = customColorOption
customColorValue.value = modelValue.replace('#', '')
customColorValue.value = modelValue
}
}
})
@@ -74,7 +75,7 @@ onMounted(() => {
watch(selectedColorOption, (newOption, oldOption) => {
if (newOption.name === '_custom') {
// Inherit the color from previous selection
customColorValue.value = oldOption.value.replace('#', '')
customColorValue.value = oldOption.value
} else {
emit('update:modelValue', newOption.value)
}
@@ -82,7 +83,7 @@ watch(selectedColorOption, (newOption, oldOption) => {
watch(customColorValue, (newValue) => {
if (selectedColorOption.value.name === '_custom') {
emit('update:modelValue', newValue ? `#${newValue}` : null)
emit('update:modelValue', newValue || null)
}
})
</script>

View File

@@ -2,11 +2,7 @@
<Dialog v-model:open="visible" :modal="false">
<DialogPortal>
<DialogOverlay />
<DialogContent
size="md"
:aria-labelledby="titleId"
@pointer-down-outside="onPointerDownOutside"
>
<DialogContent size="md" :aria-labelledby="titleId">
<DialogHeader>
<DialogTitle :id="titleId">
{{ $t('g.customizeFolder') }}
@@ -92,21 +88,6 @@ const emit = defineEmits<{
const titleId = useId()
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
// overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
function onPointerDownOutside(
event: CustomEvent<{ originalEvent: PointerEvent }>
) {
const target = event.detail.originalEvent.target
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
event.preventDefault()
}
}
const nodeBookmarkStore = useNodeBookmarkStore()
const iconOptions = [

View File

@@ -0,0 +1,110 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import FormColorPicker from './FormColorPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
color: {
hex: 'Hex',
rgba: 'RGBA',
saturationBrightness: 'Color saturation and brightness',
hue: 'Hue',
alpha: 'Alpha'
}
}
}
})
function renderForm(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const result = render(FormColorPicker, {
global: { plugins: [i18n] },
props: { modelValue: '000000', ...props }
})
return { ...result, user }
}
describe('FormColorPicker', () => {
it('preserves the legacy no-# storage contract on commit', async () => {
const onUpdate = vi.fn()
const { user } = renderForm({
modelValue: '000000',
label: 'Color',
'onUpdate:modelValue': onUpdate
})
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
await user.clear(input)
await user.type(input, '#abcdef{enter}')
expect(onUpdate).toHaveBeenLastCalledWith('abcdef')
})
it('does not commit incomplete hex while typing', async () => {
const onUpdate = vi.fn()
const { user } = renderForm({
modelValue: '000000',
label: 'Color',
'onUpdate:modelValue': onUpdate
})
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
await user.clear(input)
await user.type(input, '#ab')
expect(onUpdate).not.toHaveBeenCalled()
})
it('reverts to current value when partial entry is committed', async () => {
const onUpdate = vi.fn()
const { user } = renderForm({
modelValue: '282828',
label: 'Color',
'onUpdate:modelValue': onUpdate
})
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
await user.clear(input)
await user.type(input, '#ab{enter}')
expect(onUpdate).not.toHaveBeenCalled()
expect(input.value).toBe('282828')
})
it('accepts 8-digit hex (with alpha) on commit', async () => {
const onUpdate = vi.fn()
const { user } = renderForm({
modelValue: '000000',
label: 'Color',
'onUpdate:modelValue': onUpdate
})
const input = screen.getByPlaceholderText('Color') as HTMLInputElement
await user.clear(input)
await user.type(input, '#11223344{enter}')
expect(onUpdate).toHaveBeenLastCalledWith('11223344')
})
it('disables both inputs when disabled prop is set', () => {
const { container } = renderForm({
modelValue: '000000',
label: 'Color',
disabled: true
})
const textInput = screen.getByPlaceholderText('Color') as HTMLInputElement
expect(textInput.disabled).toBe(true)
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container -- the picker trigger has no stable accessible name
const trigger = container.querySelector(
'.color-picker-wrapper > button'
) as HTMLButtonElement | null
expect(trigger).not.toBeNull()
expect(trigger?.disabled).toBe(true)
})
})

View File

@@ -1,20 +1,76 @@
<template>
<div class="color-picker-wrapper flex items-center gap-2">
<ColorPicker v-model="modelValue" v-bind="$attrs" />
<InputText v-model="modelValue" class="w-28" :placeholder="label" />
<ColorPicker
:id="id"
v-model="hexValue"
:disabled="disabled"
:aria-labelledby="ariaLabelledby"
/>
<Input
v-model="draftText"
class="w-28"
:placeholder="label"
:disabled="disabled"
@blur="commitDraft"
@keydown.enter="commitDraft"
/>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import InputText from 'primevue/inputtext'
import { computed, ref, watch } from 'vue'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import Input from '@/components/ui/input/Input.vue'
const modelValue = defineModel<string>('modelValue')
defineProps<{
const {
disabled = false,
id,
ariaLabelledby
} = defineProps<{
label?: string
disabled?: boolean
id?: string
ariaLabelledby?: string
}>()
defineOptions({
inheritAttrs: false
})
// Preserve the PrimeVue ColorPicker storage contract (hex without `#`); the
// underlying picker uses `#`-prefixed hex, so normalize on read/write.
const hexValue = computed<string>({
get: () =>
modelValue.value?.startsWith('#')
? modelValue.value
: `#${modelValue.value ?? '000000'}`,
set: (next) => {
modelValue.value = next.replace(/^#/, '')
}
})
// Free-text draft so partial typing (e.g. "#f") doesn't roundtrip through
// the picker and snap back to black. Only commit on blur or Enter when the
// input fully parses as 6- or 8-digit hex.
const draftText = ref(modelValue.value ?? '')
watch(modelValue, (next) => {
draftText.value = next ?? ''
})
const FULL_HEX = /^#?([0-9a-f]{6}|[0-9a-f]{8})$/i
function commitDraft() {
const raw = draftText.value.trim()
if (raw === '') {
draftText.value = modelValue.value ?? ''
return
}
if (FULL_HEX.test(raw)) {
modelValue.value = raw.replace(/^#/, '').toLowerCase()
} else {
draftText.value = modelValue.value ?? ''
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
@@ -46,7 +46,10 @@
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div @contextmenu.capture="clearContextMenuTarget">
<div
class="min-w-0 overflow-x-hidden"
@contextmenu.capture="clearContextMenuTarget"
>
<DataTable
v-model:selection="selectedCommandData"
v-model:expanded-rows="expandedRows"
@@ -60,6 +63,7 @@
selection-mode="single"
context-menu
striped-rows
:table-style="{ tableLayout: 'fixed', width: '100%' }"
:pt="{
header: 'px-0'
}"
@@ -71,12 +75,11 @@
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1 truncate"
class="flex min-w-0 items-center gap-1 truncate"
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
:title="slotProps.data.id"
>
@@ -103,53 +106,38 @@
<Column
field="keybindings"
:header="$t('g.keybinding')"
:style="{ width: '30%' }"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
v-if="slotProps.data.keybindings.length > 0"
class="flex items-center gap-1"
>
<template
v-for="(binding, idx) in (
slotProps.data as ICommandData
).keybindings.slice(0, 2)"
:key="binding.combo.serialize()"
>
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="slotProps.data.isModified"
/>
</template>
<span
v-if="slotProps.data.keybindings.length > 2"
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{
$t('g.nMoreKeybindings', {
count: slotProps.data.keybindings.length - 2
})
}}
</span>
</div>
<span v-else>-</span>
<KeybindingList
:keybindings="slotProps.data.keybindings"
:is-modified="slotProps.data.isModified"
/>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:style="{ width: '16%' }"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
<span class="block truncate" :title="slotProps.data.source">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<Column
field="actions"
header=""
:style="{ width: '9rem' }"
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
>
<template #body="slotProps">
<div class="actions flex flex-row justify-end">
<div
class="actions flex flex-row justify-end whitespace-nowrap"
>
<Button
v-if="slotProps.data.keybindings.length === 1"
v-tooltip="$t('g.edit')"
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingList from './keybinding/KeybindingList.vue'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'

View File

@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import KeybindingList from './KeybindingList.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
nMoreKeybindings: '+ {count} more',
nMoreKeybindingsCompact: '+ {count}',
keybindingListAriaLabel: 'Keybindings: {combos}'
}
}
}
})
function makeKeybinding(key: string, ctrl = false, shift = false) {
return new KeybindingImpl({
commandId: 'test.cmd',
combo: { key, ctrl, shift }
})
}
function renderList(props: {
keybindings: KeybindingImpl[]
isModified?: boolean
}) {
return render(KeybindingList, {
props,
global: { plugins: [i18n] }
})
}
describe('KeybindingList', () => {
it('renders "-" placeholder when there are no keybindings', () => {
renderList({ keybindings: [] })
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
})
it('renders a single keybinding without any "more" badge', () => {
renderList({ keybindings: [makeKeybinding('A', true)] })
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-wide')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-medium')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-compact')
).not.toBeInTheDocument()
})
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
})
expect(
screen.queryByTestId('keybinding-list-more-wide')
).not.toBeInTheDocument()
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
'+ 1 more'
)
expect(
screen.getByTestId('keybinding-list-more-compact')
).toHaveTextContent('+ 1')
})
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
renderList({
keybindings: [
makeKeybinding('A', true),
makeKeybinding('B', true),
makeKeybinding('C', true)
]
})
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
'+ 1 more'
)
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
'+ 2 more'
)
expect(
screen.getByTestId('keybinding-list-more-compact')
).toHaveTextContent('+ 2')
})
it('uses a container query parent so the visible tier can adapt to width', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
})
expect(screen.getByTestId('keybinding-list').className).toContain(
'@container/keybindings'
)
})
it('emits an accessible label listing all combos', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
})
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
expect(ariaText).toContain('Keybindings:')
expect(ariaText).toContain('Ctrl')
expect(ariaText).toContain('A')
expect(ariaText).toContain('Shift')
expect(ariaText).toContain('B')
})
})

View File

@@ -0,0 +1,74 @@
<template>
<span
v-if="keybindings.length > 0"
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
data-testid="keybinding-list"
>
<KeyComboDisplay
:key-combo="keybindings[0].combo"
:is-modified="isModified"
/>
<template v-if="keybindings.length >= 2">
<span
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
aria-hidden="true"
>
,
</span>
<KeyComboDisplay
class="hidden @[16rem]/keybindings:inline-flex"
:key-combo="keybindings[1].combo"
:is-modified="isModified"
/>
</template>
<span
v-if="keybindings.length > 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
data-testid="keybinding-list-more-wide"
>
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
</span>
<span
v-if="keybindings.length >= 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
data-testid="keybinding-list-more-medium"
>
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
</span>
<span
v-if="keybindings.length >= 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
data-testid="keybinding-list-more-compact"
>
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
</span>
<span class="sr-only" data-testid="keybinding-list-aria">
{{ ariaLabel }}
</span>
</span>
<span v-else>-</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import KeyComboDisplay from './KeyComboDisplay.vue'
const { keybindings, isModified = false } = defineProps<{
keybindings: KeybindingImpl[]
isModified?: boolean
}>()
const { t } = useI18n()
const ariaLabel = computed(() => {
if (keybindings.length === 0) return ''
const combos = keybindings
.map((binding) => binding.combo.toString())
.join(', ')
return t('g.keybindingListAriaLabel', { combos })
})
</script>

View File

@@ -0,0 +1,180 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ErrorOverlay from './ErrorOverlay.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
useNodeErrorFlagSync: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: false,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id),
getActiveGraphNodeIds: vi.fn(() => new Set()),
getExecutionIdByNode: vi.fn(),
getNodeByExecutionId: vi.fn()
}))
const mockOpenPanel = vi.hoisted(() => vi.fn())
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ openPanel: mockOpenPanel })
}))
const mockCanvasStore = vi.hoisted(() => ({
linearMode: false,
canvas: null,
currentGraph: null,
updateSelectedItems: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
dismiss: 'Dismiss'
},
errorOverlay: {
errorCount: '{count} ERROR | {count} ERRORS',
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.',
viewDetails: 'View details'
},
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
}
function makeNodeError(messages: string[]): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: messages.map((message) => ({
type: 'execution_error',
message,
details: 'details'
}))
}
}
function renderOverlay(props: { appMode?: boolean } = {}) {
const pinia = createPinia()
setActivePinia(pinia)
return render(ErrorOverlay, {
props,
global: {
plugins: [pinia, createTestI18n()],
stubs: {
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
}
})
}
describe('ErrorOverlay', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
mockOpenPanel.mockClear()
mockCanvasStore.linearMode = false
mockCanvasStore.canvas = null
mockCanvasStore.currentGraph = null
mockCanvasStore.updateSelectedItems.mockClear()
})
it('renders a single overlay message without list markup', async () => {
renderOverlay()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByRole('status')).toHaveTextContent('Only error')
expect(screen.getByRole('status')).not.toHaveTextContent('1 ERROR')
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'View details'
)
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
it('keeps the app mode button label', async () => {
renderOverlay({ appMode: true })
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'Show errors in graph'
)
})
})

View File

@@ -6,15 +6,15 @@
>
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
<div
role="alert"
aria-live="assertive"
role="status"
aria-live="polite"
data-testid="error-overlay"
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
<span class="flex-1 text-sm font-bold text-destructive-background">
{{ errorCountLabel }}
{{ overlayTitle }}
</span>
<Button
variant="muted-textonly"
@@ -28,20 +28,11 @@
<!-- Body -->
<div class="px-4 pb-3" data-testid="error-overlay-messages">
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="(message, idx) in overlayMessages"
:key="idx"
class="flex min-w-0 items-baseline gap-2 text-sm/snug text-muted-foreground"
>
<span
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
/>
<span class="line-clamp-3 wrap-break-word whitespace-pre-wrap">{{
message
}}</span>
</li>
</ul>
<p
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
<!-- Footer -->
@@ -60,7 +51,11 @@
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
{{ appMode ? t('linearMode.error.goto') : seeErrorsLabel }}
{{
appMode
? t('linearMode.error.goto')
: t('errorOverlay.viewDetails')
}}
</Button>
</div>
</div>
@@ -69,73 +64,22 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
defineProps<{ appMode?: boolean }>()
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { allErrorGroups } = useErrorGroups(ref(''))
const singleErrorType = computed(() => {
const types = new Set(allErrorGroups.value.map((g) => g.type))
return types.size === 1 ? [...types][0] : null
})
const overlayMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
if (group.type === 'execution') {
// TODO(FE-816 overlay-redesign): Keep runtime overlay copy raw until the
// overlay redesign decides how to use catalog toast fields.
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
} else {
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)
})
const seeErrorsLabel = computed(() => {
const labelMap: Record<string, string> = {
missing_node: t('errorOverlay.showMissingNodes'),
missing_model: t('errorOverlay.showMissingModels'),
swap_nodes: t('errorOverlay.showSwapNodes'),
missing_media: t('errorOverlay.showMissingMedia')
}
if (singleErrorType.value) {
return labelMap[singleErrorType.value] ?? t('errorOverlay.seeErrors')
}
return t('errorOverlay.seeErrors')
})
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const isVisible = computed(
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
)
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()

View File

@@ -0,0 +1,337 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useErrorOverlayState } from './useErrorOverlayState'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
useNodeErrorFlagSync: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: false,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id),
getActiveGraphNodeIds: vi.fn(() => new Set()),
getExecutionIdByNode: vi.fn(),
getNodeByExecutionId: vi.fn()
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorOverlay: {
errorCount: '{count} ERROR | {count} ERRORS',
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.'
}
}
}
})
}
function makeNodeError(messages: string[]): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: messages.map((message) => ({
type: 'execution_error',
message,
details: 'details'
}))
}
}
function mountOverlayState() {
const pinia = createPinia()
setActivePinia(pinia)
const Harness = defineComponent({
setup() {
return useErrorOverlayState()
},
template: `
<section>
<span data-testid="visible">{{ isVisible }}</span>
<span data-testid="title">{{ overlayTitle }}</span>
<span data-testid="message">{{ overlayMessage }}</span>
</section>
`
})
return render(Harness, {
global: {
plugins: [pinia, createTestI18n()]
}
})
}
describe('useErrorOverlayState', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
})
it('uses the raw message for a single uncataloged execution error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('Execution failed')
expect(screen.getByTestId('message')).toHaveTextContent('Only error')
})
it('uses toast copy for a single validation error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Required input is missing'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Required input is missing',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'Required input is missing',
toastTitle: 'Required input missing',
toastMessage: 'KSampler is missing a required input: model'
}
]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent(
'Required input missing'
)
expect(screen.getByTestId('message')).toHaveTextContent(
'KSampler is missing a required input: model'
)
})
it('uses display copy before raw copy when toast copy is absent', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Raw validation error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Friendly validation title',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'Raw validation error',
displayMessage: 'Friendly validation message'
}
]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent(
'Friendly validation title'
)
expect(screen.getByTestId('message')).toHaveTextContent(
'Friendly validation message'
)
})
it('uses toast copy for a single runtime error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Generation failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'torch.OutOfMemoryError: CUDA out of memory',
toastTitle: 'Generation failed',
toastMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Generation failed')
expect(screen.getByTestId('message')).toHaveTextContent(
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
)
})
it('uses group toast copy for a single missing media error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
const missingMediaStore = useMissingMediaStore()
missingMediaStore.setMissingMedia([
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'image.png',
isMissing: true
}
])
mockAllErrorGroups.value = [
{
type: 'missing_media',
groupKey: 'missing_media',
displayTitle: 'Media input missing',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.',
priority: 3
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Media input missing')
expect(screen.getByTestId('message')).toHaveTextContent(
'Load Image is missing a required media file.'
)
})
it('does not show when a raw error has no resolved overlay message', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('false')
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
})
it('uses aggregate copy for multiple errors', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError([
'First error',
'Second error',
'Third error',
'Fourth error',
'Fifth error',
'Sixth error',
'Seventh error'
])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'First error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
expect(screen.getByTestId('message')).toHaveTextContent(
'Resolve them before running the workflow.'
)
})
})

View File

@@ -0,0 +1,103 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
function resolveSingleOverlayCopy(
group: ErrorGroup
): { title?: string; message: string } | undefined {
if (group.type === 'execution') {
const [card] = group.cards
const [error] = card?.errors ?? []
const message =
error?.toastMessage ??
error?.displayMessage ??
error?.message ??
group.displayMessage ??
group.displayTitle
if (!message) return undefined
return {
title: error?.toastTitle ?? error?.displayTitle ?? group.displayTitle,
message
}
}
const message =
group.toastMessage ?? group.displayMessage ?? group.displayTitle
if (!message) return undefined
return {
title: group.toastTitle ?? group.displayTitle,
message
}
}
export function useErrorOverlayState() {
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { totalErrorCount, isErrorOverlayOpen } =
storeToRefs(executionErrorStore)
const { allErrorGroups } = useErrorGroups('')
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
const singleErrorGroup = computed(() =>
hasExactlyOneError.value && allErrorGroups.value.length === 1
? allErrorGroups.value[0]
: undefined
)
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const multipleErrorCountLabel = computed(() =>
t(
'errorOverlay.multipleErrorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const singleOverlayCopy = computed(() =>
singleErrorGroup.value
? resolveSingleOverlayCopy(singleErrorGroup.value)
: undefined
)
const overlayMessage = computed(() => {
if (hasMultipleErrors.value) {
return t('errorOverlay.multipleErrorsMessage')
}
return singleOverlayCopy.value?.message ?? ''
})
const overlayTitle = computed(() =>
hasMultipleErrors.value
? multipleErrorCountLabel.value
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
)
const isVisible = computed(
() =>
isErrorOverlayOpen.value &&
totalErrorCount.value > 0 &&
overlayMessage.value.trim().length > 0
)
return {
isVisible,
overlayMessage,
overlayTitle
}
}

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