Compare commits

...

181 Commits

Author SHA1 Message Date
Alexander Piskun
09942a5b7f Merge branch 'main' into feat/RemoteComboOptions 2026-05-04 19:15:08 +03:00
Christian Byrne
560e53c68f fix: remove coming soon badge from parallel job execution (#11819)
*PR Created by the Glary-Bot Agent*

---

Removes the "coming soon" badge from the Parallel Job Execution feature
card on the cloud pricing page (`comfy.org/cloud/pricing`).

## Changes

- `apps/website/src/components/pricing/WhatsIncludedSection.vue`: drop
`isComingSoon: true` from feature11 so it renders with the standard
check icon and no badge.

The `isComingSoon` mechanism (clock icon + yellow badge) is preserved in
the component for future use on other features.

## Note

The FAQ copy elsewhere on the site (`cloud.faq.9.a`) still references
"one active job at a time" and "parallel runs soon". That copy will be
updated separately.

## Verification

- `pnpm typecheck` (website): 0 errors
- `pnpm lint`: clean (1 pre-existing warning unrelated to this change)
- `pnpm format:check`: clean
- `pnpm test:unit` (website): 20 passed
- Visual check via Playwright on local dev server (see screenshot)

## Screenshots

![Pricing page after change: Parallel job execution row shows green
check icon and no coming soon
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/54c41067c2ba0bce5de11dd3b919e3c370be4eba2fd44eb3c411921f34bc088e/pr-images/1777688853166-87c5c07e-e4ad-4ef3-a892-f3e01e2f980f.png)

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11819-fix-remove-coming-soon-badge-from-parallel-job-execution-3546d73d365081d19060f976095d03ac)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:27:31 +00:00
Yourz
1999b7fba0 fix: remove (beta) from cloud.faq.3.a (#11905)
*PR Created by the Glary-Bot Agent*

---

## Summary

Remove `(beta)` from the `cloud.faq.3.a` translation entry in both
English and Simplified Chinese (`zh-CN`), since Comfy Cloud is no longer
in beta.

## Changes

`apps/website/src/i18n/translations.ts`:
- en: `Comfy Cloud (beta) has zero setup...` → `Comfy Cloud has zero
setup...`
- zh-CN: `Comfy Cloud(测试版)无需任何设置...` → `Comfy Cloud 无需任何设置...`

## Verification

- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:website)
all passed
- Code review (oracle): 0 issues, ready to merge
- Manual verification via Playwright on `/cloud` and `/zh-CN/cloud` —
FAQ item 3 renders updated copy in both locales (screenshots attached)

## Screenshots

![English FAQ item 3 expanded — 'Comfy Cloud has zero setup...' (no
beta)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906512798-b5b8fc07-1ed1-43e2-88f5-35efd6ee7254.png)

![Simplified Chinese FAQ item 3 expanded — 'Comfy Cloud 无需任何设置...' (no
测试版)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906513275-1c0c0f6b-0408-4cc2-93e6-4a5e0d02a101.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11905-fix-remove-beta-from-cloud-faq-3-a-3566d73d36508150997bcf2c89826091)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:03:06 +00:00
Christian Byrne
285421a87c feat: add queue progress overlay feature survey (#11560)
*PR Created by the Glary-Bot Agent*

---

## Summary

Registers a new nightly feature survey for the Queue Progress Overlay
using the existing feature-survey registry (same pattern as the merged
node-search survey, PRs #8175/#8355/#9934).

- New registry entry `queue-progress-overlay` → Typeform `HZ5saxry`,
threshold **16**, 5s display delay.
- `trackFeatureUsed()` wired at the major user-initiated handlers inside
the overlay so the survey triggers regardless of panel location
(floating-right v1 or docked-left v2).
- Run button and other ActionBar items that the overlay pops over from
are deliberately **not** tracked — tracking is scoped to interactions
that originate inside the job panel / queue progress overlay itself.

## Tracked interactions

Both variants share most sub-components, so tracking is instrumented
once at each logical surface:

- **`QueueProgressOverlay.vue`** (v1 container): `viewAllJobs`,
`interruptAll`, `cancelQueuedWorkflows`, `onClearHistoryFromMenu`,
`toggleAssetsSidebar`, `onCancelItem`, `onDeleteItem`, `inspectJobAsset`
- **`QueueOverlayExpanded.vue`**: job tab switches
- **`JobHistorySidebarTab.vue`** (v2 docked): job tab switches,
`clearQueuedWorkflows`, `onClearHistory`, `onCancelItem`,
`onDeleteItem`, `onViewItem`
- **`JobFilterActions.vue`** (shared): workflow filter + sort mode
selections
- **`JobHistoryActionsMenu.vue`** (shared): docked-history toggle +
run-progress-bar toggle

Deliberately **not tracked** to keep the signal clean:
- Hover handlers (ambient preview behaviour)
- Search-box keystrokes (debounced typing)
- Context menu open and menu-item dispatch — menu actions either bubble
through already-tracked terminal handlers (e.g. inspect-asset →
`onViewItem`) or are secondary operations (copy-id, open-workflow,
download). Avoids double-counting per code review feedback.

## How it works (inherits from existing infrastructure)

1. `surveyRegistry.ts` drives `NightlySurveyController` →
`NightlySurveyPopover`, which handles the Typeform embed.
2. Eligibility already gated on `isNightly && !isCloud && !isDesktop`,
once-per-user, 4-day global cooldown across all surveys, and opt-out.
3. Typeform response routing to #C0ALLT6Q3SQ is handled on the Typeform
side.

## Verification

- `pnpm typecheck` 
- `pnpm lint`  (no new warnings)
- `pnpm knip` 
- `pnpm test:unit` on `src/components/queue`,
`src/components/sidebar/tabs/JobHistorySidebarTab`,
`src/platform/surveys` → **123/123 passing**
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck) all
pass
- Manual: dev server + backend boot cleanly, app loads without new
runtime errors, `localStorage['Comfy.FeatureUsage']` layout verified to
match what `useFeatureUsageTracker` writes

## Notes

- Survey key `queue-progress-overlay` covers both v1 (floating-right)
and v2 (docked-sidebar) per product guidance: _"This should trigger
regardless of the location of the panel (docked from left or floating on
right)."_ Both surfaces are the same product feature — the survey is
intentionally scoped to the whole job-panel experience.


## Screenshots

![App loads cleanly with the new survey code in place — empty canvas
with Run button and sidebar, no runtime
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fd18977704544ba278ad3fa42c695289ae7e02001550ce38955d6fb47d872146/pr-images/1776914667332-03e4ef0a-4137-47c6-87b8-b554770b8900.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11560-feat-add-queue-progress-overlay-feature-survey-34b6d73d3650819a9a50fd67fd9b5941)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:50:18 +00:00
Christian Byrne
5523df1aea fix(website): unstretch See all case studies button (#11854)
*PR Created by the Glary-Bot Agent*

---

## Summary

The "See all case studies" button on the homepage
`CaseStudySpotlightSection` was rendering oddly stretched because it had
`class="flex-1 text-center"` while being the sole child of a `flex-row`
container — it expanded to fill the entire content column (~592px)
instead of sizing to its label.

This drops `flex-1`/`text-center` and adds `items-start` to the wrapper
so the button sizes to its content and is left-aligned, matching the
proportions of every other outline `BrandButton` on the site (Hero,
UseCase, customer detail, etc.).

## Changes

- `apps/website/src/components/home/CaseStudySpotlightSection.vue`:
remove `flex-1 text-center` from the `BrandButton` and align the row's
items to the start.

`BrandButton` already centers its label internally via `inline-flex …
justify-center`, so dropping `text-center` is a no-op visually.

## Before / After

- Desktop before: button width = 592px (stretched across the column)
- Desktop after: button width = 223px (natural)
- Mobile: 1-column layout, now consistently left-aligned

## Review Focus

Whether the fix should also live on the `BrandButton` component itself
(e.g. a global `max-width`) instead of at the call site. I went with the
instance-level fix because every other CTA in the website intentionally
uses bare `BrandButton` and lets the content size it; only this one had
`flex-1`. A blanket `max-width` would risk changing Hero/MobileMenu
buttons that explicitly opt into `w-full lg:w-auto lg:min-w-60`.

## Screenshots

![Before: button stretched across the full content
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244354-4dd9af45-2458-4d8a-a1a7-1f6b88b6fc4b.png)

![After: button sized to content,
left-aligned](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244808-5bab2801-0140-4b4a-9d9e-61a467090de3.png)

![After: mobile view, left-aligned natural
width](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774245316-1ca9609d-3de0-4c85-973e-a87e296fa65f.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11854-fix-website-unstretch-See-all-case-studies-button-3556d73d365081abb3bbe9dbc51cbc07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:03:39 +00:00
Christian Byrne
65876c635d feat(website): add responsive media tooling for marketing assets (#11869)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the building blocks for a responsive media system on
`apps/website`, motivated by the gallery video blurriness raised in
Slack. Three independent pieces:

1. **`<SiteVideo>` Vue component + URL helper** — emits a `<video>` with
multiple `<source>` tags, designed to pair with assets named
`${name}-${width}.${format}` on `media.comfy.org`.
2. **`scripts/process-videos.sh`** — local-developer `ffmpeg` helper
that produces VP9/WebM + H.264/MP4 variants and a poster JPG. Not wired
into CI; the team uploads to `media.comfy.org` out-of-band.
3. **Marketing image conventions** — shared `MARKETING_FORMATS` /
`MARKETING_WIDTHS` constants and a README documenting how to render
local marketing images via Astro's built-in `<Picture>` from
`astro:assets`.

This PR is **infrastructure only** — no existing pages are modified.
Adoption (e.g. converting `HeroSection`, gallery videos) is a follow-up.
The new files are added to knip's ignore list with the existing "pending
stacked PR" pattern.

## Why this shape

- **No custom `<Picture>` wrapper.** Astro 5 already ships a
`ResponsiveImage` component (name conflict), and Astro's
`LocalImageProps | RemoteImageProps` discriminated union does not
survive a thin wrapper without unsafe `as` casts. Shared constants give
the consistency benefit at lower cost.
- **No CI media-upload step.** The `Release: Website` workflow currently
only refreshes the Ashby snapshot; wiring GCS uploads into it would
require new secrets and team coordination beyond this PR's scope. The
script runs locally and outputs are uploaded to `media.comfy.org` the
same way as today.
- **Single resolution per `<video>`.** `<source media="...">` inside
`<video>` is unreliable across browsers (Safari ignores it). The script
generates multiple widths so callers can pick one per page; JS-based
selection can be layered on later if metrics demand it.

## What's verified

- `pnpm --filter @comfyorg/website test:unit` — 30 pass (7 new for
`buildVideoSources` / `videoKey`)
- `pnpm --filter @comfyorg/website typecheck` — clean
- `pnpm --filter @comfyorg/website build` — 41 pages built clean
- `pnpm knip` — exit 0
- `oxfmt --check` and `oxlint` clean on all changed files
- `bash -n` on `process-videos.sh` clean; usage and missing-deps paths
exercised manually
- Manual: home page and `/gallery` rendered via `astro dev` — both
unchanged with zero console errors (screenshots attached)

## Review feedback addressed

After Oracle review, three follow-up commits land:

- **`SiteVideo` reactivity** — `sources` is now `computed`; the
`<video>` is keyed on the joined source URLs so it remounts when the
source set changes (browsers don't reload on `<source>` mutation).
- **`SiteVideo` accessibility** — `aria-hidden="true"` only when truly
decorative (no `alt` and no `controls`).
- **Shell script robustness** — probes duration with `ffprobe` and falls
back to `t=0` for clips shorter than 1s; enables `nocaseglob` so
`CLIP.MP4` is picked up.
- **Docs** — clarifies when to use `<SiteVideo>` (lightweight
multi-source) vs `<VideoPlayer>` (captions, controls, scrubber).

## Out of scope (follow-ups)

- Converting existing pages (`HeroSection`, customer detail heros,
gallery) to use the new components. Most current images are CDN-hosted
and migrating them is a separate decision.
- Re-encoding the gallery videos at a higher source width to actually
fix the blurriness — that requires the team to run `process-videos.sh`
against the source clips and re-upload.
- Combining `<SiteVideo>`'s multi-source support with `<VideoPlayer>`'s
rich chrome.

## Screenshots

![Home page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791647863-fb1ea2bf-32fc-40d9-852d-cceb3bc148f7.png)

![Gallery page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791648186-0b598260-a836-4866-9c55-9d0e99de6d4c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11869-feat-website-add-responsive-media-tooling-for-marketing-assets-3556d73d3650818899c7f9ed3204c9a5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:25:20 +00:00
bigcat88
28c97d3687 fix: use "item.id" if both label and title are missing. 2026-05-04 15:18:21 +03:00
jaeone94
04918360eb Use hash lookup for missing asset detection (#11873)
## Summary

Use exact BLAKE3 hash lookups first for missing model/media detection,
and add a separate public-inclusive input asset cache so public input
assets are considered missing-detection candidates without changing the
user-only input assets shown in the UI.

## Changes

- **What**:
- Added `assetService.checkAssetHash()` for `HEAD
/api/assets/hash/{hash}` status-only existence checks.
- Added strict BLAKE3 hash helpers so only `blake3:<64 hex>` media
values and raw 64-hex BLAKE3 model metadata are sent to the hash
endpoint.
- Updated missing media detection to group BLAKE3 candidates by hash,
resolve them through the hash endpoint, and fall back to the legacy
asset list path for invalid/unverifiable/non-hash values.
- Updated missing model detection to use hash lookup for BLAKE3-backed
asset-supported candidates before falling back to the existing node-type
asset matching path.
- Added `assetService.getInputAssetsIncludingPublic()` backed by a
dedicated cache that fetches input assets with `include_public=true` for
missing media fallback checks.
- Kept `assetsStore.inputAssets` user-only for widget/UI display, while
invalidating the public-inclusive missing-detection cache when input
assets may change.
- Added abort handling for paginated asset fetches and shared
public-input cache callers so one aborted caller does not cancel the
shared fetch for other callers.
- Added regression coverage for hash lookup, fallback behavior, abort
paths, public input fallback detection, and cache invalidation.
- **Dependencies**: None.
- **Change size**:
  - Production code: 4 files, 400 insertions, 24 deletions, net +376.
  - Test code: 4 files, 806 insertions, 59 deletions, net +747.
  - Total: 8 files, 1206 insertions, 83 deletions, net +1123.

## Review Focus

- The public-inclusive input asset cache is intentionally separate from
`assetsStore.inputAssets`. The existing store data is user-only and
drives the asset widgets/sidebar, so using it for missing input
detection misses public assets. Making that store public-inclusive would
change UI data semantics; this PR instead keeps the UI dataset unchanged
and adds a missing-detection-specific cache in `assetService`.
- Hash lookup is only used when the workflow exposes a valid BLAKE3
hash. Filename-like values and invalid hash values still use the legacy
fallback path.
- Missing model detection keeps the existing fallback behavior for
non-hash candidates and for hash checks that are invalid or fail
transiently.
- Async model download cache refresh behavior is left unchanged; this PR
avoids coupling model download completion to input asset cache
invalidation.
- No browser/e2e test was added because this changes the missing asset
detection data path, not UI interaction or rendering. The behavioral
coverage is in unit tests for the asset service and the missing
media/model scanners.

## Follow-up Items

- Fix `assetsStore.updateAssetTags()` partial-failure recovery. If
`removeAssetTags()` succeeds and `addAssetTags()` fails, the local model
asset cache can roll back to tags that the backend has already removed;
this should be handled in a focused model asset cache PR.
- Consider extracting shared hash-verification flow used by missing
media and missing model scans after this behavior stabilizes.
- Consider adding a concurrency cap or short-lived request cache for
large workflows with many unique hash lookups.
- Consider splitting `assetService.ts` further, e.g. hash helpers, abort
utilities, and the public-inclusive input asset cache.
- Consider tightening the asset hash service API shape so callers do not
directly depend on HTTP-oriented statuses such as `invalid`.
- Consider adding broader mutation-path coverage for public-inclusive
input cache invalidation once the cache has more consumers.

Linear: FE-534

## Screenshots (if applicable)

Before <false positive / missing image / public asset>


https://github.com/user-attachments/assets/db7ce2a9-b169-4fae-bf9f-98bb93d3ee6d

After 


https://github.com/user-attachments/assets/29af9f9e-b536-4fcd-a426-3add40bcb165



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11873-Use-hash-lookup-for-missing-asset-detection-3556d73d36508165babafb16614be0d8)
by [Unito](https://www.unito.io)
2026-05-04 03:59:54 +00:00
bigcat88
97c2a0d364 feat(widgets): rich combo widget for remote options with previews
Adds a Vue-native renderer for combo inputs that declare `remote_combo=`
(RemoteComboOptions on the backend). Wired through WidgetSelect; runs in
parallel to the existing useRemoteWidget composable, which continues to
handle plain `remote=` combos.

The widget fetches a single items array from a relative `/proxy/...`
route — the frontend always prepends the comfy-api base URL and injects
auth headers (no opt-out flag while the feature is partner-node-only).
Items are mapped via the per-node `item_schema`, with image/video/audio
previews, search across multiple fields, optional auto-select first/last,
and a refresh button.

Caching: browser Cache API with TTL from `refresh`, partitioned by full
auth scope (workspace / firebase uid / api-key / anon). Refresh button
sequences cache delete before refetch to avoid the fast-response race.
Logging: auth headers and response bodies are redacted from error logs.

Also adds an audio preview branch to FormDropdownMenuItem — used by the
new widget when `preview_type='audio'`.

Tests cover: single-shot fetch, error classification, retry exhaustion,
refresh, deselect, stale-id preservation, cache-key partitioning,
route resolution, item-schema mapping, and Zod relative-route
validation.
2026-05-03 14:15:32 +03:00
Dante
af70d88860 fix: keep finished badge fully opaque in ProgressToastItem (#11542)
## Summary
- fix
**[slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776801170742579)**
- Move `opacity-50` off the row container onto the asset-name column
only, so the contrast badge (white pill, dark label) is not dimmed to
gray-on-gray when a download completes.
- Matches the Figma intent that the `FINISHED` badge stands out —
designer spec uses `base/foreground` for pill, `base/background` for
text, which is unreadable when the parent is 50% opacity.

<img width="560" height="269" alt="Screenshot 2026-04-23 at 2 46 17 PM"
src="https://github.com/user-attachments/assets/fb84aa57-c348-4a86-9a65-9342c12400e1"
/>
<img width="764" height="332" alt="Screenshot 2026-04-23 at 2 46 41 PM"
src="https://github.com/user-attachments/assets/ecbe6a5f-c2e8-4427-9c1d-f8f123009d2e"
/>


## Before / After

![before /
after](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/jaewon/fe-237-fix-honeytoast-badge-finished-opacity/.github/pr-images/fe-237-before-after.png)

## Repro
Cloud → trigger a model download → wait for completion → the `FINISHED`
badge is the same tone as the toast surface (see Slack thread
screenshots).

## Test plan
- [ ] Complete a model download in cloud and confirm the `FINISHED`
badge is clearly legible in both themes.
- [ ] File name + subtitle still appear dimmed to signal the row is
completed.
- [ ] Running / failed / pending states unchanged.

- Fixes
[FE-237](https://linear.app/comfyorg/issue/FE-237/fix-honeytoast-badge-text-color-for-finished-job-matches-background)
2026-05-03 08:40:27 +00:00
Christian Byrne
c955309b26 [chore] Update Comfy Registry API types from comfy-api@911406c (#11518)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 911406c
- Generated on: 2026-04-17T16:10:40Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11518-chore-Update-Comfy-Registry-API-types-from-comfy-api-911406c-3496d73d36508146a1e2e1ee90640fa4)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-03 01:01:41 -07:00
Comfy Org PR Bot
7abd9d12c8 chore(website): refresh Ashby roles snapshot (#11851)
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.

The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.

Triggered by workflow run `25260868155`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11851-chore-website-refresh-Ashby-roles-snapshot-3546d73d365081579f98f13f7b58e611)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-02 14:15:34 -07:00
Yourz
dd9cb42fa1 feat: add Run your first workflow button to home hero (#11848)
## Summary

Add an outline-style `BrandButton` to the right side of the home page
hero section linking to the workflows page.

## Changes

- **What**: 
- Added a `Run your first workflow` outline button below the subtitle in
`apps/website/src/components/home/HeroSection.vue`, linking to
`externalLinks.workflows`. Mirrors the button pattern from
`product/local/HeroSection.vue`.
- Added `hero.runFirstWorkflow` i18n entry (en + zh-CN) in
`apps/website/src/i18n/translations.ts`.

## Review Focus

- Confirmed alignment with design spec.

<img width="1505" height="776" alt="image"
src="https://github.com/user-attachments/assets/215e667d-1827-447b-99b8-eba8cb5ec7e3"
/>
<img width="335" height="700" alt="image"
src="https://github.com/user-attachments/assets/aeac0876-74c3-4e12-a4b3-203f9e541bc2"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11848-feat-add-Run-your-first-workflow-button-to-home-hero-3546d73d365081358d54eddfda71111e)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-05-02 20:18:28 +00:00
pythongosssss
ccd19d8695 test: add metadata parser coverage (#11307)
## Summary
Adds tests for metadata parsers

## Changes

- **What**: 
- add test file generation script
- identified & fixed bug in webp exif parsing over-reading
- identified & fix bug in mp3/ogg parser where it would read from a
fixed position instead of relative, causing incorrect reads throwing
RangeError
- added catch in latent + json parsing to resolve errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11307-test-add-metadata-parser-coverage-3446d73d36508108ac36dddcec0a54d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-02 13:18:45 -07:00
Yourz
809fba7b36 feat: add Groove Jones customer story (#11849)
## Summary

Add a new customer story for Groove Jones — Crocs x NFL FOOH holiday
campaign for Dick's Sporting Goods, delivered with Comfy.

## Changes

- **What**:
- New entry in
[`apps/website/src/config/customerStories.ts`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/feat/groove-jones-customer-story/apps/website/src/config/customerStories.ts)
registering slug `groove-jones` with cover image hosted on
`media.comfy.org/website/customers/groove-jones/`.
- Added `customers.story.groove-jones.{category,title,body}` and
`customers.detail.groove-jones.topic-1` … `topic-10` translations (en +
zh-CN) in
[`apps/website/src/i18n/translations.ts`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/feat/groove-jones-customer-story/apps/website/src/i18n/translations.ts).
10 sections matching design sidebar: INTRO, THE OUTPUT, THE PROBLEM, HOW
COMFY SOLVED THE PROBLEM, BRAND-TRAINED LORAS, MULTI-MODEL
ORCHESTRATION, THE PIPELINE, VERSION CONTROL, FINISHING IN NUKE, THE
TAKEAWAY.
- Includes 2 pull quotes (Doug Hogan, Dale Carman), 1 final blockquote +
author card, and 3 inline images.
- Routes `/customers/groove-jones` and `/zh-CN/customers/groove-jones`
are auto-generated by `[slug].astro`.

## Review Focus

- Contributors author card uses `TBD` placeholder names/roles — to be
filled in.
- No `readMoreHref` set yet (no public blog post URL).
- All 4 images uploaded to
`gs://comfy-org-videos/website/customers/groove-jones/` and served via
`media.comfy.org`.

<img width="1000" height="535" alt="Kapture 2026-05-02 at 23 17 04"
src="https://github.com/user-attachments/assets/28654d24-0d49-4303-82ac-b6923cd6bc93"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11849-feat-add-Groove-Jones-customer-story-3546d73d36508128a64bd6809ad77447)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-05-02 18:31:18 +00:00
Christian Byrne
df2ae6f2d0 fix(load3d): dispose THREE.Points GPU resources in clearModel() (#11836)
Fixes #11345

## Summary

`clearModel()` in `SceneModelManager` only traversed and disposed
`THREE.Mesh` instances, leaving `THREE.Points` objects (created by
`handlePLYModeSwitch()` for point-cloud mode) leaking GPU geometry and
material memory on repeated point-cloud loads/clears.

## Changes

- `SceneModelManager.ts`: extend the dispose traversal in `clearModel()`
to also handle `THREE.Points`, mirroring the pattern already used by
`removeAllMainModelsFromScene()`.
- `SceneModelManager.test.ts`: add regression test verifying
`geometry.dispose()` and `material.dispose()` are called for
`THREE.Points` children on `clearModel()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11836-fix-load3d-dispose-THREE-Points-GPU-resources-in-clearModel-3546d73d365081718338e824bc3e737d)
by [Unito](https://www.unito.io)
2026-05-02 12:39:12 +00:00
Christian Byrne
3c7781190a feat(website): add llms.txt for GEO discovery by AI search agents (#11830)
## Summary

Adds `/llms.txt` at the apex following the [llms.txt
standard](https://llmstxt.org) — a curated, link-based markdown file
that tells LLM-based search agents (ChatGPT search, Perplexity, Claude
search, Google AI Overviews, etc.) what's most important on the site. It
complements `robots.txt` (crawler permissions) and `sitemap-index.xml`
(URL inventory) by giving AI agents a short, prose-friendly index they
can ingest into a context window.

## What's in the file

28 links across 6 sections:

- **Product** (6) — homepage, Local download, Cloud, Cloud pricing, API,
Enterprise
- **Workflows and Gallery** (2) — gallery + community workflows site
- **Customers and Case Studies** (5) — customers index + 4 named studios
(Series Entertainment, Moment Factory, Ubisoft Chord, Open Story
Movement)
- **Developers and Documentation** (4) — docs.comfy.org, ComfyUI repo,
Comfy-Org GitHub org, registry.comfy.org
- **Company** (6) — about, careers, contact, blog, privacy, terms
- **Optional** (5) — `zh-CN` locale variant, long-form enterprise case
studies, blog posts (de-prioritized per spec — agents can skip if
context-limited)

The intro paragraph names the four product surfaces (Local, Cloud, API,
Enterprise), the named customers, and the use-case industries (VFX &
animation, advertising, gaming, eCommerce/fashion) — so an agent that
ingests only the prose still gets the elevator pitch.

## Verification

- All 28 URLs verified live (`HTTP 200`) before commit.
- File is plain markdown — no build step. Astro/Vercel will serve it
from `apps/website/public/llms.txt` exactly as it serves `robots.txt`
(which lives in the same directory and ships at
`https://comfy.org/robots.txt`).
- Will verify on the Vercel preview deploy after this PR opens that
`curl -sI https://<preview>/llms.txt` returns `200` with a sensible
`content-type`. (`robots.txt` currently serves as `text/plain;
charset=utf-8` — `.txt` will likely do the same; that's fine for AI
agents.)

## Decisions

- **No `llms-full.txt` yet.** That variant inlines full prose of key
pages and requires curating substantive content. Deferred to a follow-up
— the marketing-site pages are mostly Vue-rendered hero/feature blocks
rather than long-form prose, so a meaningful `llms-full.txt` would need
either dedicated copy or a build step that flattens i18n strings +
section text. Tracking separately.
- **No comment line in `robots.txt`.** I considered adding a `# AI
agents: see /llms.txt` comment above the `Sitemap:` directive, but
decided against it: (a) the convention is to probe the well-known path
`/llms.txt` directly, not to discover it via robots.txt; (b)
`robots.txt` was just polished in #11823 with a deliberate compact
design and adding a non-standard comment would muddy that; (c) zero
implementations I checked actually parse robots.txt for llms.txt hints.
Easy to add later if needed.

## Context

Third of three follow-ups from the SEO/GEO sweep on 2026-05-02:

1. ~~Comfy-Router: add `X-Content-Type-Options: nosniff` to apex
security headers~~ (separate PR on `Comfy-Org/comfy-router`)
2. ~~Cloudflare: enable "Always Use HTTPS"~~ (dashboard toggle, no PR)
3. **This PR** — add `llms.txt` for GEO discovery

## Testing

- [x] All linked URLs return 200
- [x] File parses as valid markdown
- [ ] Preview deploy serves `/llms.txt` (will verify once preview is up)

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11830-feat-website-add-llms-txt-for-GEO-discovery-by-AI-search-agents-3546d73d365081a98c6bfc5301699f64)
by [Unito](https://www.unito.io)
2026-05-02 07:16:27 +00:00
Christian Byrne
167a1e6a0c fix(website): override X-Robots-Tag to index,follow on production deployment (#11831)
## P0 SEO fix — entire marketing site is currently `noindex`

A deep-audit sweep on **2026-05-02** confirmed that every page on
`comfy.org` (home, `/about`, `/pricing`, `/customers`, `/careers`, …) is
being served with:

```
x-robots-tag: noindex
```

This hides the entire marketing site from Google and other search
engines.

### Root cause

`comfy.org` reaches the Astro marketing site via a Cloudflare Worker
reverse proxy
([`comfy-router`](https://github.com/Comfy-Org/comfy-router)) that does:

```js
fetch('https://website-frontend-comfyui.vercel.app/...')
```

Per Vercel's KB article [Avoiding duplicate-content with vercel.app
URLs](https://vercel.com/kb/guide/avoiding-duplicate-content-with-vercel-app-urls):

> By default, Vercel adds an `X-Robots-Tag: noindex` HTTP header to all
deployments hosted on `vercel.app` URLs. […] This header tells search
engines like Google not to index these deployment URLs.

Because `website-frontend-comfyui.vercel.app` is **not** registered as a
Production custom domain on the Vercel project, Vercel applies that
header — and our Worker faithfully forwards it back to `comfy.org`
clients (and Googlebot).

### Fix

Vercel's documented workaround for proxied `*.vercel.app` setups is to
override the header via `vercel.json` ([blog post by Dan
Denney](https://www.dandenney.com/posts/front-end-dev/fixing-x-robots-nofollow-with-vercel/)):

```json
"headers": [
  {
    "source": "/(.*)",
    "has": [
      { "type": "host", "value": "website-frontend-comfyui.vercel.app" }
    ],
    "headers": [
      { "key": "X-Robots-Tag", "value": "index, follow" }
    ]
  }
]
```

### Why the `has` host filter

Critical scoping detail: without the `has` filter, the override would
also apply to PR-preview deployments at
`comfy-website-preview-pr-N.vercel.app`. We want previews to **stay**
`noindex` to avoid duplicate-content competition with production.

The filter pins the override to the production deployment hostname only.
The hostname matches `WEBSITE_ORIGIN` in `comfy-router/src/index.js`:

```js
const WEBSITE_ORIGIN = 'https://website-frontend-comfyui.vercel.app';
```

### Defense in depth

A parallel PR on `comfy-router` (TBD) will also strip `X-Robots-Tag:
noindex` at the Worker layer, so the public `comfy.org` response is
correct even if a future Vercel project change re-introduces the
upstream header.

### Verification (after merge + Vercel production deploy)

```bash
# Production should no longer be noindex
curl -sI https://comfy.org/ | grep -i x-robots-tag
curl -sI https://comfy.org/about | grep -i x-robots-tag
curl -sI https://comfy.org/pricing | grep -i x-robots-tag
# Expect: empty output, OR "x-robots-tag: index, follow"

# Direct Vercel production hostname
curl -sI https://website-frontend-comfyui.vercel.app/ | grep -i x-robots-tag
# Expect: "x-robots-tag: index, follow"

# Preview deploys must stay noindex (proves the host filter works)
curl -sI https://comfy-website-preview-pr-<N>.vercel.app/ | grep -i x-robots-tag
# Expect: "x-robots-tag: noindex"
```

### Pre-merge state (for the record)

```
$ curl -sI https://comfy.org/ | grep -iE 'x-robots-tag|x-served-by'
x-served-by: vercel-website
x-robots-tag: noindex
```

### Scope

Minimum delta — only `apps/website/vercel.json`. No other files touched.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11831-fix-website-override-X-Robots-Tag-to-index-follow-on-production-deployment-3546d73d365081489b62e6633d25dfe5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <christian@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-05-01 23:03:25 -07:00
Alexander Brown
e4e1546458 test: add queue notification banners lifecycle browser tests (#11366)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds `browser_tests/tests/queueNotificationBanners.spec.ts` covering
`useQueueNotificationBanners` composable E2E behavior
- Adds `data-testid="queue-notification-banner"` to
`QueueNotificationBannerHost.vue` for stable test targeting
- Registers the new test ID in `TestIds.queue.notificationBanner`

### Test coverage added (7 tests)

| Group | Tests | Behavior |
|---|---|---|
| Queuing lifecycle | 4 | `promptQueueing` → banner appears,
`promptQueued` upgrades to queued, batch plural text, requestId mismatch
doesn't upgrade |
| Auto-dismiss | 1 | Banner disappears after 4s timeout |
| FIFO queue | 1 | Second notification shows after first auto-dismisses
|
| Direct queued | 1 | `promptQueued` without prior `promptQueueing`
shows banner directly |

### Approach
Tests dispatch `promptQueueing`/`promptQueued` custom events directly
via `window.app.api.dispatchCustomEvent()` inside `page.evaluate()`,
matching how `app.ts` triggers these events during real queue
operations. This avoids needing a running execution pipeline while
exercising the full composable → component → DOM rendering chain.

### Verification
- TypeScript: zero errors
- ESLint: clean
- oxlint: clean
- oxfmt: formatted
- Playwright execution requires running ComfyUI backend (not available
in sandbox)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11366-test-add-queue-notification-banners-lifecycle-browser-tests-3466d73d36508172a7ffd3fe3b4fd925)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-02 05:40:45 +00:00
Christian Byrne
c1954028d1 feat(ci): add Release:Website workflow to refresh Ashby snapshot (#11829)
## Summary

Adds a unified manual-trigger release flow for the `apps/website`
package
(careers/marketing site at comfy.org), mirroring how main-app releases
work.

**User-facing flow:**

```
workflow_dispatch ──► fresh Ashby pull ──► auto-PR with snapshot bump
                                                      │
                                                      ▼
                              existing CI / Vercel preview deploy
                                                      │
                                                      ▼
                                  human merges ──► auto prod deploy
```

The careers data on comfy.org comes from Ashby and is fetched at build
time by
`apps/website/src/utils/ashby.ts`. Without `WEBSITE_ASHBY_API_KEY`, the
build
falls back to a committed snapshot at
`apps/website/src/data/ashby-roles.snapshot.json`. That snapshot has
been
going stale because no CI workflow was passing the API key. This PR
fixes
both: a manual refresh workflow + day-to-day secrets wiring.

## Files

**Added**
- `.github/actions/ashby-pull/action.yaml` — composite action that runs
`pnpm --filter @comfyorg/website ashby:refresh-snapshot` with the Ashby
  secrets piped in. Uses the existing `setup-frontend` composite for
  pnpm/Node setup.
- `.github/workflows/release-website.yaml` — `workflow_dispatch`-only
  release workflow. Checks out `main`, refreshes the snapshot via the
  composite action, opens a PR labelled `Release:Website` via
  `peter-evans/create-pull-request@c0f553fe…` (the same SHA pin used by
  `release-version-bump.yaml`).

**Modified**
- `.github/workflows/ci-website-build.yaml` — adds
`WEBSITE_ASHBY_API_KEY`
  and `WEBSITE_ASHBY_JOB_BOARD_NAME` env to the `Build website` step.
- `.github/workflows/ci-vercel-website-preview.yaml` — adds the same env
  to both `vercel build` steps (preview + production).

## Snapshot fallback preserved

`apps/website/src/utils/ashby.ts` keeps using the committed snapshot
when
the API key is absent (e.g. fork PRs, secret rotation). Verified
locally:

```
$ pnpm --filter @comfyorg/website ashby:refresh-snapshot
Snapshot refresh aborted. Outcome: stale; reason: missing WEBSITE_ASHBY_API_KEY...
```

The release workflow surfaces this as a job failure, which is the
desired
behavior for a manual release trigger.

## Validation

- `yamllint --config-file .yamllint` on all changed YAML — clean
- `./scripts/cicd/check-yaml.sh` — clean
- `pinact run --check` on new files — clean (matches `.pinact.yaml`
policy)
- `pnpm install --frozen-lockfile` — works with `.nvmrc` Node 24
- Husky pre-commit hooks (eslint + typecheck + lint-staged) passed

## Caveats

- **Cannot fully end-to-end test until merged.** `workflow_dispatch`
  workflows only run from branches that exist on `main`. The first
  manual run can only happen after this PR lands. The pieces that
  *can* be tested pre-merge — yamllint, pinact pin validation, and
  the existing `CI: Website Build` / `CI: Vercel Website Preview`
  workflows now picking up the secret — will run on this PR.
- **`Release:Website` label needs to be created** in the repo before
the auto-PR step will successfully apply it.
`peter-evans/create-pull-request`
  will warn but not fail if the label doesn't exist. Suggested color:
  `#4f6ef5` (matches `cloud/*` family in `release-branch-create.yaml`).
- The release workflow uses `secrets.PR_GH_TOKEN` (matching
  `release-version-bump.yaml`) so the auto-PR can be authored by a
  PAT and trigger downstream CI workflows. Without `PR_GH_TOKEN` it
  will fall back behavior is up to GitHub Actions defaults — confirm
  the secret exists before the first run.

## Context

Came out of work on `comfy-router#22` + `ComfyUI_frontend#11823`
(comfy.org/countdown subpage / website refresh). Discovered the
8+-day-stale snapshot while auditing the website build path.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11829-feat-ci-add-Release-Website-workflow-to-refresh-Ashby-snapshot-3546d73d3650811eb300d8bcb593c652)
by [Unito](https://www.unito.io)
2026-05-02 05:31:00 +00:00
Christian Byrne
5cad2c952b refactor+test: extract useSubscriptionCheckout composable, rewrite tests (#11396)
## Summary

Adds 20 component tests for
`SubscriptionRequiredDialogContentWorkspace.vue` covering:

- **Initial rendering**: pricing table display, close/back button
visibility, out_of_credits reason message
- **Close button**: calls onClose callback
- **Subscribe click flow**: pricing→preview transitions (new
subscription & upgrade), error toasts for disallowed/missing/failed
previews, monthly billing cycle
- **Back button**: returns from preview to pricing step
- **Add credit card**: handles subscribed status (success toast +
close), needs_payment_method (opens Stripe URL), error state
- **Confirm transition**: success path with close emit, error toast on
failure
- **Resubscribe**: success path with toast + close, error toast on
failure

## Testing

```bash
pnpm test:unit -- src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts
```

All 20 tests pass. Quality gates (typecheck, lint, format, knip) pass.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11396-test-add-component-tests-for-SubscriptionRequiredDialogContentWorkspace-3476d73d36508156a218dcb67a2a334e)
by [Unito](https://www.unito.io)
2026-05-02 04:50:44 +00:00
Daxiong (Lin)
e356addeb6 feat: add model links for default workflow (#11308)
We now support detecting the missing models when loading the workflow.
But the default workflow didn't include an embedded model link,

so users don't know where to download the model or which one to use.

Users will see an error when loading the default workflow every time, so
I updated it to include the model link.

Before
<img width="1920" height="1050" alt="image"
src="https://github.com/user-attachments/assets/08774480-78ae-41b4-85bd-64b431079ec1"
/>

After

<img width="1920" height="1050" alt="image"
src="https://github.com/user-attachments/assets/dcec5a02-94ad-416f-9881-d761f4137fbd"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11308-feat-add-model-links-for-default-workflow-3446d73d365081188978e1d313c38ffe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-02 04:24:08 +00:00
Christian Byrne
e831daae59 feat(website): point robots.txt at /sitemap-index.xml + AI crawler rules (#11823)
## Summary

Once
[comfy-router#22](https://github.com/Comfy-Org/comfy-router/pull/22)
ships, `comfy.org/sitemap-index.xml` will return a unified index
aggregating both the website (38 URLs) and workflow-templates sitemaps.
This PR:

1. Reverts `Sitemap:` back to `/sitemap-index.xml` (was `/sitemap-0.xml`
in #11802 as a workaround for the 404).
2. Adds explicit allow records for 21 search and AI/LLM crawlers
(GPTBot, ChatGPT-User, OAI-SearchBot, Google-Extended, ClaudeBot,
Claude-Web, anthropic-ai, PerplexityBot, Perplexity-User,
Applebot-Extended, Bytespider, Amazonbot, CCBot, Meta-ExternalAgent,
Meta-ExternalFetcher, Diffbot, etc.).
3. Adds `Disallow:` for `/_astro/`, `/_website/`, `/_vercel/` — Vercel
build artifacts that aren't useful to crawl.

## Why granular UAs

Stacked `User-agent:` records (per [RFC 9309
§2.2](https://datatracker.ietf.org/doc/html/rfc9309#section-2.2)) share
one rule block. Listing each bot explicitly:

- Signals intent to AI bots that look for their UA in robots.txt before
crawling more aggressively.
- Surfaces our crawl policy clearly to anyone inspecting the file.
- Lets us add per-bot Disallows in future without restructuring.

## Merge order

⚠️ **Do NOT merge until comfy-router#22 is deployed to production.**
Until then, `/sitemap-index.xml` returns 404 and this PR would re-break
the issue PR #11802 patched. Verification:

```bash
curl -sI https://comfy.org/sitemap-index.xml
# expect: HTTP/2 200, x-served-by: worker-sitemap-index
```

Once that returns 200, this is safe to merge.

## Verification (after merge + deploy)

```bash
# robots.txt is served and points at the unified index
curl -s https://comfy.org/robots.txt | grep '^Sitemap:'
# → Sitemap: https://comfy.org/sitemap-index.xml

# Each AI crawler can fetch it
for ua in 'GPTBot/1.0' 'ClaudeBot/1.0' 'PerplexityBot/1.0' 'Google-Extended' 'Applebot-Extended'; do
  curl -s -o /dev/null -w "$ua → %{http_code}\n" -A "$ua" https://comfy.org/robots.txt
done

# Sitemap is reachable from robots.txt
SITEMAP=$(curl -s https://comfy.org/robots.txt | awk -F': ' '/^Sitemap:/ {print $2}')
curl -s "$SITEMAP" | xmllint --noout - && echo "valid XML"
```

## Linear / closes

- Closes FE-437 (AI crawler rules)
- Updates FE-432 — the robots.txt change in #11802 was a workaround
that's no longer needed once #22 ships

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11823-feat-website-point-robots-txt-at-sitemap-index-xml-AI-crawler-rules-3546d73d3650811dbceedd06c00db444)
by [Unito](https://www.unito.io)
2026-05-01 21:04:45 -07:00
Robin Huang
96575fcec9 feat: redesign cloud onboarding survey for ICP and persona signal (#11628)
## Summary

Replaces the 4-step Cloud onboarding survey with a 7-step flow that
captures both ICP attributes and user persona dimensions. The survey
questions are now populated dynamically from remoteConfig.

## Changes

- **What**: New survey questions — Usage, Familiarity, Role, Team size,
Industry, Making, Source. Role / Team size / Industry are gated to
"Work" usage; Education users see a Student / Educator short list for
Role. Most option lists are randomized per visit (familiarity and team
size stay ordered as ordinals). \`SurveyResponses\` extended with
optional \`usage\`, \`role\`, \`teamSize\`, \`source\` fields.
- **Breaking**: None — \`useCase\` and \`workflowRelationship\` remain
optional in the type and existing telemetry normalization keeps working
unchanged.

## Review Focus

- The \`role\` step has a function-form \`options\` so the list can swap
based on \`usage\`. \`steps\` is a computed that filters by
\`showWhen()\` and resolves the option function — verify reactivity when
\`usage\` changes.
- Changing \`usage\` clears the previously-picked \`role\` via a watcher
to prevent a stale value from carrying over between Work / Education
modes.
- Per-visit shuffle is stable: option lists are passed through
\`randomize()\` once at module load, not on every render.

## Screenshots


https://github.com/user-attachments/assets/3602a388-50dc-401e-ada9-ea9016c5052d


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11628-feat-redesign-cloud-onboarding-survey-for-ICP-and-persona-signal-34d6d73d365081f4a792cfe76a987ffb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-05-01 23:25:17 +00:00
Kelly Yang
e7e1ae25a6 fix(load3d): suppress error toast on 404 when loading output model file (#11807)
## Summary

- Adds `silentOnNotFound` option to `LoadModelOptions` interface,
threaded through `Load3d.loadModel` → `LoaderManager.loadModel`
- 404 errors (detected via message text or `response.status`) are
silently swallowed when `silentOnNotFound: true`; all other errors still
surface a toast
- Sets `silentOnNotFound: true` for output-folder loads in `load3d.ts`
and `saveMesh.ts` — covers shared workflows opened on a machine that
never ran them

## Test plan

- [x] `LoaderManager.test.ts` — 40 unit tests covering 404 suppression,
non-404 still toasts, stale load handling
- [x] `Load3DConfiguration.test.ts` — 4 unit tests verifying
`silentOnNotFound` propagates correctly through `configureForSaveMesh`
and `configure`
- [x] `load3d.spec.ts` — 2 E2E tests: 404 → no toast, 500 → toast
appears

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes error-handling behavior in the 3D model loading pipeline and
extends method signatures/options; risk is mainly missed call sites or
incorrectly classifying non-404 errors as 404 and hiding real failures.
> 
> **Overview**
> Prevents noisy user-facing toasts when an *output* 3D model referenced
by `Preview3D`/`SaveGLB` is missing locally by adding a
`silentOnNotFound` flag and suppressing the "Error loading model" toast
specifically for HTTP 404 failures.
> 
> Threads the new `LoadModelOptions` through `Load3d.loadModel` →
`LoaderManager.loadModel` and updates `Load3DConfiguration`/callers to
opt in for output-folder loads, with new unit + Playwright coverage (404
stays silent, non-404 still toasts).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
049f75ef60. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11807-fix-load3d-suppress-error-toast-on-404-when-loading-output-model-file-3536d73d36508129ac0de1d5b081dcf0)
by [Unito](https://www.unito.io)
2026-05-01 18:49:31 -04:00
Yourz
4ed00cec08 update: robots.txt to point to /sitemap-0.xml (#11802)
## Summary

<!-- One sentence describing what changed and why. -->

robots.txt at [comfy.org](https://comfy.org/) references
/sitemap-index.xml which returns 404. The actual working sitemap is at
/sitemap-0.xml (200, 38 URLs). This blocks search engines from
discovering the sitemap.

## Changes

- **What**: <!-- Core functionality added/modified -->
  - Update robots.txt to point to /sitemap-0.xml, OR

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11802-update-robots-txt-to-point-to-sitemap-0-xml-3536d73d365081bb9545eb96dd1e8025)
by [Unito](https://www.unito.io)
2026-05-01 19:36:22 +00:00
Alexander Brown
f566abdd6e ci: extract changes-filter composite action; fix docs-only PR stall (#11785)
## Summary

Extract a `.github/actions/changes-filter` composite action and adopt it
across path-gated CI workflows, fixing the docs-only PR stall and
removing duplicated `paths:` / `paths-ignore:` filtering across 8
workflows.

## Background

Docs-only PRs stalled on required status checks because workflows using
`paths-ignore: ['**/*.md']` never created a check run, while branch
protection still required it. Observed on #11776 (the `test` check from
`ci-tests-unit.yaml` never appeared).

The fix pattern: keep the workflow triggered, gate downstream jobs on a
`changes` job whose outputs are computed from a path filter. Skipped
jobs count as passing under branch protection.

## What the action emits

| Output | Meaning |
|---|---|
| `should-run` | Any file outside `apps/`, `docs/`, `.storybook/`,
`**/*.md` changed. |
| `app-website-changes` | Shared deps or `apps/website/**` changed. |
| `app-desktop-changes` | Shared deps or `apps/desktop-ui/**` changed. |
| `app-frontend-changes` | Shared deps or `src/**` changed. |
| `packages-changes` | Shared deps or `packages/**` changed. |
| `storybook-changes` | Shared deps or `.storybook/**` changed. |
| `docs-changes` | `docs/**` or any `**/*.md` changed (deps NOT folded
in). |
| `dependency-changes` | Root `package.json`, `pnpm-lock.yaml`, or
`pnpm-workspace.yaml` changed. |

Shared deps are folded into every `app-*`, `packages-changes`, and
`storybook-changes` output so a lockfile bump correctly invalidates each
granular gate. Outputs default to `'true'` for non-`pull_request` events
to avoid the silent-skip footgun on push / merge_group.

## Workflows migrated

| Workflow | Gate | Notes |
|---|---|---|
| `ci-tests-unit.yaml` | `should-run` | Required check (`test`). Fixes
the original stall. |
| `ci-tests-e2e.yaml` | `should-run` | Required check (`e2e-status`).
Replaces inline filter. |
| `ci-perf-report.yaml` | `should-run` | Removes `paths-ignore`. |
| `ci-website-build.yaml` | `app-website-changes \|\| packages-changes`
| Refactor — not a required check, but moves to job-level gating. Filter
scope broadens from `packages/{design-system}` to all `packages/**`
(strictly safer). |
| `ci-website-e2e.yaml` | `app-website-changes \|\| packages-changes` |
Same restructure; `post-starting-comment` also gated to avoid spurious
"tests are running" when E2E is skipped. |
| `ci-dist-telemetry-scan.yaml` | `should-run` | New gate; was
previously running on every PR including docs-only. |
| `ci-oss-assets-validation.yaml` | `should-run` | Same. |
| `ci-size-data.yaml` | `should-run` | Preserves existing repository
guard on the new `changes` job. |
| `ci-tests-storybook.yaml` | `storybook-changes \|\|
app-frontend-changes \|\| packages-changes` | Gates 4 of 6 jobs.
`deploy-production` (push to main) left ungated;
`update-comment-with-chromatic` cascades naturally. |

## Branch protection (verified)

Required status checks on `main` and `core/**`/`cloud/**`: `test`,
`lint-and-format`, `e2e-status`. Only `test` and `e2e-status` use the
composite — `lint-and-format` correctly stays unfiltered (must run on
docs/apps too). The other 6 migrations are refactor wins (less wasted CI
on docs/apps-only PRs), not stall fixes.

## Changes

- **What**: New `.github/actions/changes-filter` composite + 8 workflow
migrations to consume it.
- **Breaking**: None.
- **Dependencies**: New pin on `dorny/paths-filter@de90cc6` — already
covered by `ci-validate-action-pins`.

## Review Focus

- The `should-run` filter excludes `.storybook/**` (granular
`storybook-changes` covers it instead). Storybook's gate combines all
three: `storybook-changes || app-frontend-changes || packages-changes`.
- Two `dorny/paths-filter` steps inside the composite —
`predicate-quantifier=every` is required for the negated globs in
`should-run` but breaks the multi-pattern OR filters.
- The website filter scope intentionally broadens from
`packages/{design-system,tailwind-utils}/**` to all `packages/**` for
consistency and safety.

Fixes #11776

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11785-ci-extract-changes-filter-composite-action-fix-docs-only-PR-stall-3526d73d36508172a1d7fe8c30fa6453)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 18:06:29 +00:00
pythongosssss
3c5695fd42 test: add Vue node error/validation ring e2e coverage (#11727)
## Summary

Add additional test coverage for vue node errors

## Changes

- **What**: 
- add tests for showing error on missing node, execution error,
validation failure & resolved on fix
- move ErrorsTabHelper to fixtures dir & update refs
- add SLOW_MO env var for headed local tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11727-test-add-Vue-node-error-validation-ring-e2e-coverage-3506d73d365081069ff8f70f7970dd55)
by [Unito](https://www.unito.io)
2026-05-01 09:35:29 +00:00
Dante
4fff0c4b49 fix: report total file count, not job count, in ZIP export toast (#11737)
## Summary

ZIP export toast now reports the total number of files instead of the
number of selected jobs when any selected job has multiple outputs.

## Changes

- **What**: In `downloadMultipleAssetsAsZip`
(`src/platform/assets/composables/useMediaAssetActions.ts`), compute
`fileCount` by summing each asset's `outputCount` metadata (fallback 1)
and pass it to `mediaAsset.selection.exportStarted` instead of
`assets.length`. The existing i18n string already handles `file`/`files`
plural.
- **Tests**: 3 new unit tests in `useMediaAssetActions.test.ts` covering
multi-output, single-output fallback, and mixed selections. The
`useToast` and `useI18n` mocks were lifted to hoisted refs so toast call
args are assertable.

## Review Focus

- Reduce uses `count > 1 ? count : 1`, mirroring the
`hasMultiOutputJobs` gate above so a known `outputCount === 1` is still
counted as 1 file (no double-counting).
- Only `downloadMultipleAssetsAsZip` is touched; OSS individual-download
path and direct-download path are unchanged.

Fixes #11736

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11737-fix-report-total-file-count-not-job-count-in-ZIP-export-toast-3516d73d3650811ba78dfdb0a0ae8ea1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 09:28:01 +00:00
pythongosssss
69dca2d600 test: add test coverage for workflow save settings (#11763)
## Summary

Add tests for autosave/delay/node sort on save

## Changes

- **What**: 
- add tests

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11763-test-add-test-coverage-for-workflow-save-settings-3516d73d365081d1b57bc1cf4b2e2ece)
by [Unito](https://www.unito.io)
2026-05-01 09:09:31 +00:00
Kelly Yang
004530b23a fix: search bar layout and autocomplete clipping on Desktop at small sizes (#11713)
## Summary

Fixes two visual bugs in the Desktop app at small window sizes: the
search bar getting pushed/clipped in modal headers, and autocomplete
suggestion dropdowns being cut off by `overflow-hidden` ancestors.

## Changes

- **`SearchAutocomplete.vue`**: Wrap `ComboboxContent` in
`ComboboxPortal` so the suggestions dropdown teleports to `<body>`,
escaping `overflow-hidden` ancestors (fixes z-index clipping in Manager
dialog and other modals using `BaseModalLayout`)
- **`BaseModalLayout.vue`**: Replace `shrink-0` with `min-w-0` on the
header content container so the search bar can shrink at narrow window
sizes instead of overflowing and being clipped by the modal root's
`overflow-hidden`
- **`GraphCanvas.vue`**: Fix dead code where the native drag
(`app-drag`) div was nested inside a `v-if="workflowTabsPosition ===
'Topbar'"` block with its own mutually exclusive condition — move it
before the block and add `pointer-events-auto` so Desktop window
dragging works when tabs are in Sidebar position

## Why no E2E tests

- **`SearchAutocomplete` portal**: The fix is structural (teleport to
`<body>`). A meaningful regression test would require opening the
Manager dialog with a real or mocked extension list — that is a
substantial standalone effort tracked in #11714.
- **`BaseModalLayout` header shrink**: A viewport-resize assertion would
test CSS layout behaviour, not application logic; it would be fragile
and low-value.
- **`GraphCanvas` app-drag**: Desktop/Electron-only.
`-webkit-app-region: drag` cannot be exercised in headless Chromium.

Unit tests for `SearchAutocomplete` cover the new code paths (portal
rendering, suggestion display, item selection).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk UI-only changes: adjusts layout CSS and combobox rendering
via `ComboboxPortal`, plus adds unit tests; no business logic or data
flow changes.
> 
> **Overview**
> Fixes small-window Desktop UI issues where modal-header search inputs
could be clipped and autocomplete dropdowns could be cut off by
`overflow-hidden` ancestors.
> 
> `SearchAutocomplete` now renders its suggestions list inside a
`ComboboxPortal` (teleporting the popper content outside clipping
containers) and adds a focused unit test suite covering empty/non-empty
suggestions, selection behavior, and `optionLabel` handling.
> 
> `BaseModalLayout` tweaks header flexbox constraints (`min-w-0` on the
header content container) to allow the search bar to shrink instead of
overflowing.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd32d960f9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-04-30 23:22:04 -07:00
Alexander Brown
73d4e24ffa revert: roll back #10849 + #11697 (per-instance promoted widget values) (#11790)
## Summary

Reverts #10849 (per-instance promoted widget value storage) and its
companion test-pinning PR #11697. The fix in #10849 caused regressions
in promoted-widget serialization (notably the Z-Image-Turbo template,
see #10146 follow-up). A replacement fix is being developed on
`fix/subgraph-promoted-widget-inline-state` and will land separately.

## Changes

- **Revert #11697** — drops the `it.fails`-marked tests that pin the
#10849 corruption symptom. With #10849 reverted, those markers would
falsely flip to passing.
- **Revert #10849** — removes per-instance `_instanceWidgetValues` map,
`_pendingWidgetsValues` configure-time hydration, the `widgets_values`
write path in `SubgraphNode.serialize()`, the `sourceSerialize` field on
`PromotedWidgetView`, the multi-instance Vitest suite, and the
multi-instance E2E test + asset.
- **Conflict resolution** in
`browser_tests/tests/subgraph/subgraphSerialization.spec.ts`: kept the
restored test coverage from #11579 (which is post-#10849 on main) and
removed only the now-unreachable multi-instance test, its helper, and
its workflow constant. Auto-merge with #11698 (`incrementVersion`) and
#11699 (ID type aliases) was clean.

## Review Focus

- Confirm no other on-main code path has come to depend on
`PromotedWidgetView.sourceSerialize` or
`SubgraphNode._instanceWidgetValues` since #10849 (grep is clean
locally).
- Confirm we want to land this revert before the replacement fix on
`fix/subgraph-promoted-widget-inline-state` is ready — this leaves the
original #10146 (multi-instance widget value collision) unfixed in the
meantime.
- The retained #11579 test coverage now exercises pre-#10849 behavior;
some of those assertions were written expecting the #10849 code path. CI
will surface any that need adjustment.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11790-revert-roll-back-10849-11697-per-instance-promoted-widget-values-3536d73d3650814094abd58b6b712d8d)
by [Unito](https://www.unito.io)
2026-05-01 03:37:20 +00:00
Kelly Yang
09790bd7f3 test: add unit tests for useLayoutMutations (#11313)
## Summary
A follow-up PR of
https://github.com/Comfy-Org/ComfyUI_frontend/issues/11106.
This PR only focus on `layoutMutations.ts`.
`layoutMutations.ts` is the central API for all node layout mutations in
the Vue renderer. It previously had zero test coverage despite
containing non-trivial logic such as guard clauses, ID normalization,
and z-index scanning. This PR addresses issue #11106 to prevent silent
regressions in node positioning and lifecycle.

## What was tested and how
All tests use the real `layoutStore` singleton (no mocks).
`initializeFromLiteGraph` resets node state before each test, and
results are verified via `getNodeLayoutRef().value`.

| Method | Tests | Logic covered |
| :--- | :--- | :--- |
| `moveNode` | 3 | **Guard** (missing node -> no-op); position written
to store; numeric ID coerced to string |
| `resizeNode` | 2 | **Guard**; size written to store |
| `setNodeZIndex` | 2 | **Guard**; zIndex written to store |
| `createNode` | 1 | Node becomes readable with the provided position
and size |
| `deleteNode` | 2 | **Guard**; node removed from store |
| `batchMoveNodes` | 4 | Empty array -> no-op; multiple nodes updated
**atomically**; existing size preserved; missing nodes skipped while
valid ones still update |
| `bringNodeToFront` | 1 | Target node ends up with a **higher zIndex**
than all other nodes |

## What was not tested and why

| Method | Reason skipped |
| :--- | :--- |
| `createLink` / `deleteLink` | `layoutStore` exposes no public API to
query link existence by ID; methods contain no logic beyond a straight
`applyOperation` call. |
| `createReroute` / `deleteReroute` / `moveReroute` | Same reason as
above. |
| `setSource` / `setActor` | Single-line delegation to `layoutStore`; no
logic to test. |
| Default value behavior in `createNode` | Avoids "change-detector"
tests; asserting hardcoded defaults adds no regression value and would
block valid product changes. |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only changes; no production logic is modified. Main risk is
potential flakiness due to reliance on singleton store state across
tests.
> 
> **Overview**
> Adds a new `layoutMutations.test.ts` Vitest suite that exercises
`useLayoutMutations` against the real `layoutStore` singleton
initialized from LiteGraph data.
> 
> Tests cover no-op guard clauses for missing nodes, node ID
normalization, position/size/z-index updates, node create/delete
behavior, `batchMoveNodes` semantics (empty input, skipping missing
nodes, preserving size), and `bringNodeToFront` z-index promotion
relative to other nodes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
bb92de0b5b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11313-test-add-unit-tests-for-useLayoutMutations-3446d73d36508134a289d6147244e710)
by [Unito](https://www.unito.io)
2026-05-01 03:06:29 +00:00
Kelly Yang
dafb944c3b refactor: replace PrimeVue InputText/Textarea with project UI components (#11324)
## Summary
Fix part of the
#https://github.com/Comfy-Org/ComfyUI_frontend/issues/11092
A total of 4 `// @ts-expect-error` directives were removed across 3
files — all caused by PrimeVue's legacy `$el` access pattern (`const
inputElement = inputRef.value.$el`) — by replacing PrimeVue with
Reka-based UI components. 1 corresponding unit test file was added.

## Changes
### `src/components/ui/input/Input.vue`
- **Exposed APIs**: Extended `defineExpose` to include `blur()` and
`setSelectionRange()`. This allows parent components to programmatically
control input behavior without direct DOM manipulation.

### `src/components/ui/textarea/Textarea.vue`
- **Exposed APIs**: Added `focus()` via `defineExpose`.
- **Cleanup**: Removed redundant attribute spreading (`...restAttrs`) to
lean on Vue’s default `$attrs` inheritance, making the component more
predictable.

---

## Refactored Feature Components

### `WidgetMarkdown.vue` (Note/Markdown Widgets)
- **Dependency Swap**: Replaced `primevue/textarea` with local
`Textarea.vue`.
- **Logic Simplification**: Simplified focus logic from
`textareaRef.value?.$el?.focus()` to a typed
`textareaRef.value?.focus()`.
- **Code Style**: Converted arrow functions to function declarations and
removed redundant section comments.

### `PromptDialogContent.vue` (Generic Prompt Dialogs)
- **Component Update**: Replaced PrimeVue `FloatLabel` and `InputText`
with a native `<label>` and local `Input.vue`.
- **Vue 3.5 Adoption**: Implemented **Reactive Destructuring** for
props.
- **Conflict Resolution**: Renamed internal `onConfirm` handler to
`handleConfirm` to prevent collision with destructured props.

### `EditableText.vue` (Node Titles & Sidebar Items)
- **Style Modernization**: Removed `<style scoped>` block in favor of
**Tailwind CSS** utility classes (e.g., `inline`, `w-full`).
- **Clean Implementation**: Replaced PrimeVue PassThrough (`:pt`) logic
with standard `@blur` and `v-bind` attributes.

---

## Testing & Quality Assurance

### Updated Tests
- **Redundancy Removal**: Cleaned up `EditableText.test.ts` and
`WidgetMarkdown.test.ts` by removing unused PrimeVue global
registrations. All 34 existing behavioral tests remain passing.

### New Coverage
- **`PromptDialogContent.test.ts`**: Added 3 new tests to verify:
    1. Correct initialization with `defaultValue`.
    2. Value persistence when clicking the Confirm button.
    3. Form submission via the `Enter` key.

---

## Manual Test Screenshot
All functions have passed testing.
 
<img width="594" height="530" alt="test5"
src="https://github.com/user-attachments/assets/46a6b3b2-1855-414e-ac78-65668052ce50"
/>
<img width="1190" height="1074" alt="test4"
src="https://github.com/user-attachments/assets/89aa61ab-9401-44c2-9eae-9ca8761df675"
/>
<img width="1154" height="1028" alt="test3"
src="https://github.com/user-attachments/assets/3f63cfdf-8fbd-4dd3-9e42-dbebe4d8d421"
/>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Moderate risk because it swaps underlying input/textarea components
and ref handling (focus/blur/selection) in interactive UI paths
(editable labels, prompt dialogs, markdown editor), which could subtly
change keyboard/blur behavior.
> 
> **Overview**
> Refactors several Vue components to stop using PrimeVue
`InputText`/`Textarea` (and `$el` access) in favor of the project’s
`Input`/`Textarea` components, updating bindings/events and Tailwind
classes accordingly.
> 
> Extends the shared `Input` to expose `blur`, `setSelectionRange`, and
`selectAll`, and updates `Textarea` to expose `focus`, enabling callers
to manage focus/selection without DOM internals.
> 
> Adds a new unit test suite for `PromptDialogContent` and simplifies
existing tests by removing PrimeVue plugin/component setup; the groups
e2e test replaces a screenshot assertion with a functional visibility
check for the new title input.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9c97314d59. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11324-refactor-replace-PrimeVue-InputText-Textarea-with-project-UI-components-3456d73d36508109a18bc97a7d0487a7)
by [Unito](https://www.unito.io)
2026-05-01 03:06:19 +00:00
Christian Byrne
d429d481e8 test: use real vue-i18n plugin in useReconnectingNotification tests (#11386)
## Summary

Replace `vi.mock('vue-i18n')` stub with a real `createI18n` plugin
instance in `useReconnectingNotification` tests.

## Changes

- **What**: Add `setupComposable()` helper that renders a wrapper
component via `@testing-library/vue` with a real `createI18n` plugin.
Assertions now check translated values
(`'Reconnecting'`/`'Reconnected'`) instead of raw i18n keys. Removes the
brittle `vi.mock('vue-i18n')` stub.

## Review Focus

Straightforward test-only change — the composable requires a component
setup context for `useI18n()`, so we render a thin wrapper via
`@testing-library/vue` with the i18n plugin installed.

Fixes #11153

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11386-test-use-real-vue-i18n-plugin-in-useReconnectingNotification-tests-3476d73d3650814ba70eea6df91c8bbe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 01:55:20 +00:00
Comfy Org PR Bot
a9aae6af4a 1.44.15 (#11787)
Patch version increment to 1.44.15

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11787-1-44-15-3536d73d3650814da3a6dfe300ff559c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-01 01:32:05 +00:00
jaeone94
46ba65e25c fix: hide advanced footer button on collapsed Vue nodes (#11778)
Before 


https://github.com/user-attachments/assets/82e323d7-0c9b-4303-81eb-87cb7b62f1c1


After 


https://github.com/user-attachments/assets/56f1db45-1b5d-47a9-9960-9a73deb68e78

Fixes the Vue node footer so the Advanced button only appears while a
node is expanded.

Root cause: `showAdvancedInputsButton` only checked for advanced widgets
and the global setting, so collapsed nodes with advanced widgets could
still render the Advanced tab, including the combined error + advanced
footer state.

Changes:
- Return `false` for advanced footer visibility when the Vue node is
collapsed.
- Add regression coverage for collapsed advanced nodes and collapsed
error + advanced nodes.

Validation:
- `pnpm test:unit --
src/renderer/extensions/vueNodes/components/LGraphNode.test.ts`
- Commit hook: format, stylelint, oxlint, eslint, `pnpm typecheck`
- Push hook: `pnpm knip`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11778-codex-Hide-advanced-footer-on-collapsed-Vue-nodes-3526d73d365081d79399ddda40f2a7f7)
by [Unito](https://www.unito.io)
2026-05-01 01:00:14 +00:00
jaeone94
11432f7d0e refactor: extract missing model refresh pipeline (#11751)
## Summary

Extracts the missing-model pipeline orchestration out of `ComfyApp` and
into an app-independent platform module, while tightening the
workflow-flattening type boundary that refresh needs when rescanning the
live LiteGraph graph.

This PR is intentionally refactor-heavy. It is the follow-up to the
earlier missing-model refresh work: instead of keeping refresh-specific
candidate recheck logic beside the UI, this change makes the refresh
path reuse the existing missing-model pipeline and removes the direct
dependency on private `ComfyApp` pipeline methods.

Linear: FE-499

Issues covered by this PR:

- Fixes #11678
- Fixes #11680
- Partially addresses #11679 by removing the missing-model refresh
path's unsafe `graph.serialize() as unknown as ComfyWorkflowJSON` cast
and replacing it with the narrower flattenable workflow contract.
Broader workflow serialization/type-boundary cleanup outside this
missing-model refresh path remains deferred.

## Changes

- **What**:
- Added `src/platform/missingModel/missingModelPipeline.ts` as the
orchestration module for missing-model detection/verification.
- `runMissingModelPipeline(...)` now owns the pipeline previously
embedded in `ComfyApp`:
      - candidate scan and enrichment
      - active ancestor filtering for muted/bypassed subgraph containers
      - pending warning cache updates
      - OSS folder path and file-size follow-up work
      - cloud asset verification follow-up work
- surfaced missing-model errors via the existing execution error store
- `refreshMissingModelPipeline(...)` handles the refresh-specific flow:
      - calls the injected `reloadNodeDefs()` first
      - serializes the current live graph
- preserves model metadata by preferring active workflow `models`, then
falling back to current missing-model candidate metadata
      - delegates back into the same pipeline used during workflow load
- Kept `ComfyApp` as the compatibility caller instead of the owner of
the pipeline.
- `loadGraphData(...)` now calls `runMissingModelPipeline(...)` with
`graph`, `graphData`, `missingNodeTypes`, and `silent` options.
- `refreshMissingModels(...)` is now a thin wrapper around
`refreshMissingModelPipeline(...)` and keeps the existing default
`silent: true` refresh behavior.
- The new pipeline module does not import `@/scripts/app`; app-owned
data/actions are passed in as inputs.
- Moved the workflow node-flattening helpers out of `workflowSchema.ts`
and into `src/platform/workflow/core/utils/workflowFlattening.ts`.
- This includes `flattenWorkflowNodes`, `buildSubgraphExecutionPaths`,
and `isSubgraphDefinition`.
- The move is intentional: these helpers are not zod schema definitions
or workflow validation logic. They are core workflow traversal utilities
used to flatten root workflow nodes plus nested subgraph definition
nodes into the execution-shaped node list needed by missing-model
scanning.
- The refresh path receives data from `LGraph.serialize()`, whose return
type is serialized LiteGraph data rather than validated
`ComfyWorkflowJSON`. Previously this forced unsafe typing like
`graph.serialize() as unknown as ComfyWorkflowJSON`.
- The new `FlattenableWorkflowGraph` / `FlattenableWorkflowNode`
structural contract describes only what flattening actually needs:
`nodes`, `definitions.subgraphs`, node `id`, `type`, `mode`,
`widgets_values`, and `properties`.
- This lets both normal workflow-load data (`ComfyWorkflowJSON`) and
refresh-time live graph serialization (`LGraph.serialize()`) flow into
the same scan/enrichment path without pretending serialized LiteGraph
output is a fully validated workflow schema document.
- Updated `missingModelScan.ts` to consume that minimal flattenable
workflow shape via `MissingModelWorkflowData`.
- `MissingModelWorkflowData` extends the flattenable workflow contract
with optional workflow-level `models` metadata.
- Removed now-unnecessary casts around execution IDs, flattened nodes,
and `widgets_values` object access.
- Updated `getSelectedModelsMetadata(...)` to accept readonly widget
value arrays so flattened workflow data can stay read-only.
- Reduced the exported surface of the new pipeline module after `knip`
flagged unused exported internal option/store interfaces.
- Kept `workflowSchema.ts` focused on validation schemas. The flattening
helpers are not re-exported from the schema module because they are
internal workflow core utilities, not public schema API.

- **Breaking**: None intended.
  - Internal imports were updated to the new core utility path.
- This repo is not exposing these flattening helpers as a public package
API, so the old schema-local helper location is treated as an internal
implementation detail.

- **Dependencies**: None.

## Review Focus

- **Pipeline extraction / dependency direction**:
- Please verify that `missingModelPipeline.ts` stays independent from
`@/scripts/app`.
- `ComfyApp` should remain the caller/adapter, not the owner of
missing-model pipeline orchestration.

- **Workflow flattening type boundary**:
- The main type-cleanup goal is removing the refresh-time
`graph.serialize() as unknown as ComfyWorkflowJSON` lie.
- `LGraph.serialize()` and validated workflow JSON are not the same
contract. The new flattenable workflow contract is deliberately smaller
and structural because the missing-model enrichment path only needs
enough data to flatten nodes and read embedded model metadata.
- This is why the flattening helpers moved from `workflowSchema.ts` to
`workflow/core/utils`: the logic is reusable workflow traversal, not
validation schema.

- **Behavior preservation**:
- The PR is intended to preserve existing user-facing missing-model
behavior while moving ownership out of `app.ts`.
- Existing async follow-up behavior remains intentionally
fire-and-forget:
- cloud asset verification still surfaces after verification completes
- OSS folder paths still update asynchronously before surfacing
confirmed missing models
    - file-size metadata fetching remains asynchronous
- More invasive behavior changes, such as adding non-cloud post-fetch
`isMissingCandidateActive(...)` re-verification or redesigning the
fire-and-forget result contract, are intentionally left for follow-up
work because they are not pure extraction.

- **Downloadable model metadata**:
- `missingModels` returned for download metadata now requires both `url`
and `directory`.
- Candidates without a directory still remain in `confirmedCandidates`,
but they are not exposed as downloadable model metadata. This keeps the
returned downloadable list aligned with what the download flow can
actually use.

- **Test ownership**:
- Complex missing-model pipeline behavior tests moved out of
`src/scripts/app.test.ts` and into
`src/platform/missingModel/missingModelPipeline.test.ts`.
- `app.test.ts` now only covers thin delegation for
`app.refreshMissingModels(...)`.
- Workflow flattening tests moved with the helper from schema tests into
`src/platform/workflow/core/utils/workflowFlattening.test.ts`.

- **Deferred follow-ups**:
  - Broader function decomposition for cognitive complexity.
- Wider dependency-injection/port cleanup for stores and services beyond
the app boundary.
- Cloud-specific pipeline unit tests, which need a separate `isCloud`
mocking strategy.
- Additional E2E coverage expansion beyond the existing OSS refresh
path.
- More general workflow serialization/type-boundary cleanup outside the
missing-model refresh path.

## Validation

- `pnpm format`
- `pnpm lint`
- Passed. Existing lint output included a pre-existing
`no-misused-spread` warning and icon-name logs, but the command exited
successfully.
- `pnpm typecheck`
- `pnpm test:unit`
  - `714 passed`, `9514 passed | 8 skipped`
- Pre-push `pnpm knip`
- Passed after reducing the exported surface of the new pipeline module.

## Screenshots (if applicable)

Not applicable. This PR is a pipeline/type-boundary refactor with no UI
changes.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11751-refactor-extract-missing-model-refresh-pipeline-3516d73d3650816d9245d4b1324b71c9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 00:50:51 +00:00
pythongosssss
9384beaec6 test: Add tests for bounding box widget (#11343)
## Summary

Adds coverage for the bounding box widget

## Changes

- **What**: 
- Validates user interactions and functionality on the widget

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11343-test-Add-tests-for-bounding-box-widget-3456d73d365081eb8d03f2220a837816)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-05-01 00:23:22 +00:00
pythongosssss
4a05d89fdb fix: detach DOM widget event listeners on widget removal (#11724)
## Summary

Fixes leaked event listeners

## Changes

- **What**: 
- update all listeners to use AbortController to signal removal on
widget remove

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11724-fix-detach-DOM-widget-event-listeners-on-widget-removal-3506d73d3650811dae81c034c1098759)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-01 00:17:18 +00:00
Alexander Brown
ef98ba0e8f feat: add plum/ink color primitives and standardize design tokens (#11139)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds new `plum` and `ink` color scales for Comfy Hub branding and
standardizes existing tokens to align with current Figma design system.

### Changes

**Phase 1 — New primitives** (`_palette.css`)
- Added `plum-300/400/500/600` and `ink-100` through `ink-900`

**Phase 2 — Token cleanup** (`style.css`)
- Removed deprecated `slate-100/200/300` primitives (cool blue-grey,
removed from Figma)
- Removed duplicate `graphite-400` (identical hex to slate-100)
- Dark mode: migrated 6 slate/graphite references to muted-foreground,
smoke-700, smoke-800, charcoal-200
- Light mode: replaced 3 `ash-500` references with `smoke-800` per
designer alignment

### Token migration detail

| Dark mode token | Old value | New value | Rationale |
|---|---|---|---|
| `--node-component-header-icon` | slate-300 (#5b5e7d) |
muted-foreground (smoke-800) | Figma `node/foreground-secondary` |
| `--node-component-slot-text` | slate-200 (#9fa2bd) | smoke-700
(#a0a0a0) | Lighter neutral for text contrast |
| `--node-component-surface-highlight` | slate-100 (#9c9eab) | smoke-800
(#8a8a8a) | Neutral grey highlight |
| `--node-component-tooltip-border` | slate-300 (#5b5e7d) | charcoal-200
(#494a50) | Consistent with dark border tokens |
| `--text-secondary` | slate-100 (#9c9eab) | smoke-700 (#a0a0a0) |
Adequate contrast on dark surfaces |
| `--widget-background-highlighted` | graphite-400 (#9c9eab) | smoke-800
(#8a8a8a) | Removed duplicate, neutral replacement |

### Visual note

These changes shift some dark mode colors from cool blue-grey to neutral
grey. This is intentional per the design team. The
`--node-component-surface-highlight` and
`--node-component-tooltip-border` tokens should be QA'd as the designer
noted.

### Not included (Phase 3)

Hub Dark overlay theme will ship separately once the Hub UI work is
ready to validate against.

## Screenshots

![Dark theme with templates dialog - text contrast
verified](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6425a0f072864b0f279edd70da09bf96b24eaf7d483eb695e2718e864735bea9/pr-images/1775860135830-05ba14b3-f385-4fc8-9d25-3094d8c09b74.png)

![Dark theme canvas view - sidebar and toolbar rendering
correctly](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6425a0f072864b0f279edd70da09bf96b24eaf7d483eb695e2718e864735bea9/pr-images/1775860136167-5887ce56-5eae-482e-90e5-06e24cb23d2c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11139-feat-add-plum-ink-color-primitives-and-standardize-design-tokens-33e6d73d365081418e13e0efe6161fb5)
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>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 16:15:53 -07:00
Christian Byrne
019c1787a5 [chore] Update Comfy Registry API types from comfy-api@e993818 (#11783)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: e993818
- Generated on: 2026-04-30T17:57:05Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11783-chore-Update-Comfy-Registry-API-types-from-comfy-api-e993818-3526d73d3650810e9e14f55da44714b2)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-30 21:37:01 +00:00
Alexander Brown
87fab87d84 docs: add reviewing-unit-tests skill (#11777)
## Summary

Adds `.claude/skills/reviewing-unit-tests/SKILL.md`, a project-scoped
agent skill that gives reviewing agents a concrete rubric for evaluating
Vitest unit-test diffs.

## Changes

- **What**: New skill at `.claude/skills/reviewing-unit-tests/SKILL.md`.
Mirrors the structural template of the peer skill
`.claude/skills/writing-playwright-tests/SKILL.md` (frontmatter shape,
table-driven sections, terse prescriptive prose). Description triggers
on PR review of `*.test.ts` files. Codifies: alias-by-renaming
detection, `vi.mocked()` scope rule, unnecessary-cast detection,
divergence checks against `docs/testing/unit-testing.md` /
`store-testing.md` / `component-testing.md` / `vitest-patterns.md`,
redundant `mockClear()` flag, `vue-i18n`-must-not-be-mocked rule (with
`testI18n` reference), bugfix regression-validity check, leading-close
framing flag, testing-library requirement for new component tests.

## Review Focus

- **Retrospective validation against PR #11737** (head SHA
`4573d62450fd12ac6f06e5e491f8af84ccbd27de`, file
`src/platform/assets/composables/useMediaAssetActions.test.ts`).
Mentally running the skill's checklist against that diff flags:
1.  `getToastAddMock` / `getI18nTMock` module-level helpers — caught by
the "Renaming ≠ Restructuring" section + Mocking Smells row
"Module-level helper functions wrapping mocked composable returns".
2.  `as ReturnType<typeof vi.fn>` casts in those helpers — caught by
the "`vi.mocked()` Scope" section + Mocking Smells row on stray casts.
3.  `vi.mock('vue-i18n', ...)` — caught by Mocking Smells row directing
to mount real `createI18n` per `docs/testing/vitest-patterns.md` and the
shared `testI18n` in
`src/components/searchbox/v2/__test__/testUtils.ts`.
4.  `vi.mock('primevue/usetoast', ...)` — explicitly **not** flagged.
The "Distinguish" paragraph after the Mocking Smells table marks
trivially-shaped third-party hooks as acceptable to mock; "Don't Mock
What You Don't Own" applies to behavior-rich APIs, not single-method
composables.
- **Reference style**: links to `docs/testing/*.md` and
`docs/guidance/typescript.md` instead of restating their content, per
the FE-511 acceptance criterion.
- **Open question**: scope. The repo-local skill is the FE-511
deliverable; equivalent prescriptive rules already live in the user's
global `~/.config/amp/AGENTS.md` "Code Review Rigor" section as a
separate artifact. No duplication intended — the skill is the
project-scoped surface a reviewing agent loads when entering this repo.

Closes FE-511

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11777-docs-add-reviewing-unit-tests-skill-3526d73d365081848759e6a8fab942f0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 14:29:47 -07:00
Alexander Brown
f2a99adaa3 docs: prefer real createI18n over mocking vue-i18n in tests (#11776)
## Summary

Document that `vue-i18n` should not be mocked in tests — mount with a
real `createI18n` plugin instance instead.

## Changes

- **What**: Expanded `docs/testing/vitest-patterns.md` "Don't Mock
`vue-i18n`" section with a concrete code example (covering both
component and composable tests), guidance for asserting on translation
keys with empty messages, and a real-example link to
`src/components/searchbox/v2/__test__/testUtils.ts`. Added a callout at
the top of `docs/testing/unit-testing.md` "Mocking Composables with
Reactive State" cross-linking the new section, since that section
applies to *owned* composables.

## Review Focus

- The previous `vitest-patterns.md` paragraph pointed at a non-existent
`SearchBox.test.ts`; the new link points to the actual shared `testI18n`
helper.
- The "Mocking Composables with Reactive State" pattern should not be
applied to third-party composables like `useI18n` — the callout makes
that explicit.

Surfaced during review of #11737, where the test file mocked `vue-i18n`
and then accumulated structural workarounds (hoisted aliases, helper
functions, type casts) to interact with the mocked `t`. A real
`createI18n` instance avoids the entire ceremony.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11776-docs-prefer-real-createI18n-over-mocking-vue-i18n-in-tests-3526d73d365081d4bc39fbf3c2502e49)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 14:29:11 -07:00
Robin Huang
a934056246 feat(website): add PostHog analytics (#11735)
## Summary

Adds PostHog page analytics to the marketing website (`apps/website/`).

## Changes

- **What**: New `posthog.ts` script with
`initPostHog`/`capturePageview`. Wired into `BaseLayout.astro` behind
`import.meta.env.PROD` (mirroring the GTM gate). Pageviews are captured
on every `astro:page-load` so ClientRouter view-transition navigations
are tracked, not just hard reloads.
- **Dependencies**: `posthog-js` (already in the workspace catalog at
`^1.358.1`; previously used by the workbench telemetry provider).

## Review Focus

- API host is set to `https://t.comfy.org` to match
`src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts` —
confirm that proxy is OK to share with the website surface.
- Project token is hard-coded inline. It is a public `phc_…` frontend
token (designed to ship to clients); this matches the pattern used for
`gtmId` in the same file. Happy to switch to a `PUBLIC_POSTHOG_KEY` env
var if preferred.
- `person_profiles: 'identified_only'` to avoid creating profiles for
every anonymous visitor — flag if you want full anonymous tracking
instead.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11735-feat-website-add-PostHog-analytics-3516d73d3650811189c6d64c3af4ded9)
by [Unito](https://www.unito.io)
2026-04-30 20:08:01 +00:00
pythongosssss
b8dfbfc0bb fix: ensure escape key/graph navigation cancels ghost node placement (#11779)
## Summary

When inside a subgraph, the parent key handler intercepts the escape key
before `processKey` is called, causing a stale ghost node to be added to
the inner subgraph when the graph is changed to the parent.

## Changes

- **What**: 
- move escape key handler to document, prevent propagating the event
- add saftey net cancel in setGraph
- tests

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11779-fix-ensure-escape-key-graph-navigation-cancels-ghost-node-placement-3526d73d3650812292e4ca10d384f783)
by [Unito](https://www.unito.io)
2026-04-30 17:30:04 +00:00
Comfy Org PR Bot
036c79259b 1.44.14 (#11769)
Patch version increment to 1.44.14

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11769-1-44-14-3526d73d3650816ba3a9cd87afeef035)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-30 06:09:17 +00:00
Benjamin Lu
17c18b0707 fix: embed HubSpot contact form (#11723)
## Summary

Embed the HubSpot-hosted contact sales form on `/contact` and
`/zh-CN/contact` so HubSpot owns submission handling, validation,
anti-spam/security updates, tracking context, and form configuration.

## Changes

- **What**: Replaces the custom local contact form stub with a HubSpot
embed component using the HubSpot-hosted developer/raw HTML script and
`hs-form-html` container.
- **Localization**: Uses the existing English form ID by default and
switches to the zh-CN form ID for `/zh-CN/contact`.
- **Styling**: Applies HubSpot form CSS custom properties to match the
Comfy contact page colors and `PP Formula` font more closely.
- **Docs**: Updates the website README with the developer embed snippet
and the zh-CN form ID.
- **Dependencies**: None.

## Why Embedded Form

- HubSpot docs say forms should be loaded with the HubSpot-hosted
JavaScript file, and that security, anti-spam, accessibility, and
performance improvements will not propagate if the embed runtime is
copied or self-hosted:
https://developers.hubspot.com/docs/cms/start-building/building-blocks/modules/forms
- The direct form submission endpoint is documented under `v3 legacy`:
https://developers.hubspot.com/docs/api-reference/legacy/marketing/forms/v3-legacy/submit-data-unauthenticated
- HubSpot’s legacy API overview says numeric-versioned APIs are legacy
and will be deprecated in a future release:
https://developers.hubspot.com/docs/api-reference/legacy/overview

## Review Focus

- Confirm the portal ID and form IDs are correct:
  - `en`: `94e05eab-1373-47f7-ab5e-d84f9e6aa262`
  - `zh-CN`: `6885750c-02ef-4aa2-ba0d-213be9cccf93`
- Check visual fit on `/contact` and `/zh-CN/contact`, especially font,
background, inputs, radio controls, and submit button.
- Confirm the developer/raw HTML embed remains the preferred integration
over a custom Forms API POST.

## Local Checks

- `pnpm exec oxfmt --check
apps/website/src/components/contact/HubspotFormEmbed.vue
apps/website/README.md`
- `pnpm exec eslint
apps/website/src/components/contact/HubspotFormEmbed.vue`
- `pnpm --filter @comfyorg/website typecheck`
- `pnpm --filter @comfyorg/website test:unit`
- `pnpm --filter @comfyorg/website build`
- Commit hooks: stylelint, oxfmt, oxlint, eslint, `pnpm typecheck`,
`pnpm typecheck:website`
- Push hook: `pnpm knip --cache`

Build completed with existing non-fatal environment warnings: Node
`v25.8.1` vs requested `24.x`, unresolved website font paths deferred to
runtime, `transformWithEsbuild` deprecation, and missing Ashby env
falling back to the committed snapshot.

Incredibly, during the taking of these screenshots, I discovered a bug
in macos, where despite the snapshot/record bar not existing, from me
esc'ing out, some of the tooltips persist. Closing and reopening the lid
did not fix this. I didn't see the process in activity monitor.

<img width="1512" height="862" alt="Screenshot 2026-04-29 at 7 09 55 PM"
src="https://github.com/user-attachments/assets/92518795-6845-4b34-8da3-54048b440eb1"
/>

<img width="1512" height="862" alt="Screenshot 2026-04-29 at 7 13 18 PM"
src="https://github.com/user-attachments/assets/f7609e58-898d-413c-975c-b02b70b84e73"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11723-fix-embed-HubSpot-contact-form-3506d73d365081528bfbe4b024c2a21f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-29 22:40:55 -07:00
Alexander Brown
ca8407218b chore(husky): skip pre-push knip hook in CI (#11772)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fixes a false-positive knip failure in the `i18n: Update Core` workflow
that's been blocking version-bump PRs (e.g. #11769, run
[25141091787](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25141091787/job/73690747401)).

## Root cause

1. The `update-locales` job uses `setup-comfyui-server`, which copies
`tools/devtools/*` → `ComfyUI/custom_nodes/ComfyUI_devtools/*` at the
workspace root.
2. The job's final `git push` triggers the husky `.husky/pre-push` hook,
which runs `pnpm knip`.
3. Knip's `project` glob (`**/*.{js,ts,vue}` in `knip.config.ts`) sweeps
the runtime `ComfyUI/` directory; the existing `tools/devtools/web/**`
ignore doesn't cover the copied path, so
`ComfyUI/custom_nodes/ComfyUI_devtools/web/legacyWidget.js` (added in
#11574) is reported as unused → exit 1 → push aborted.

## Why fix the hook (not knip config or workflows)

- **`knip.config.ts` ignore** (`'ComfyUI/**'`): tried first;
`treatConfigHintsAsErrors: true` makes the unused-pattern hint fatal in
normal CI runs where `ComfyUI/` doesn't exist, breaking `ci-lint-format`
everywhere.
- **`HUSKY=0` per workflow step**: works, but bots can't push workflow
file changes without `workflows` scope, and it would need to be repeated
on every CI workflow that pushes.
- **Skip the hook in CI**: the canonical `pnpm knip` check runs in
`.github/actions/lint-format-verify/action.yml` on every PR, so the
pre-push duplicate is pure dev-side guardrail. Gating it on `$CI` (set
to `true` by all GitHub Actions runners) cleanly removes it from every
CI push without affecting local developer workflow.

## Verification

Reproduced the failure locally by mirroring the CI sequence (`cp -r
tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/` then `pnpm
knip:no-cache`):

- Without fix: `[log] Unused files (1)
ComfyUI/custom_nodes/ComfyUI_devtools/web/legacyWidget.js` → exit 1
(matches CI log)
- With `CI=true`, the hook short-circuits at `exit 0` before invoking
knip
- `pnpm knip` on a clean checkout (no `ComfyUI/`) still passes

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11772-chore-husky-skip-pre-push-knip-hook-in-CI-3526d73d3650819f9a01d549d122b69c)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-30 05:16:51 +00:00
Benjamin Lu
26ac1eece1 Allow website screenshot workflow to remove its label (#11725)
## Summary

Allows the website screenshot update workflow to remove its own trigger
label when a label-triggered run completes.

## Changes

- **What**: Grants the screenshot update job `issues: write`, which is
required for `issues.removeLabel`, and keeps the cleanup scoped to
`Update Website Screenshots`.
- **Dependencies**: None.

## Review Focus

Confirm the workflow permission scope is appropriate and that unexpected
label cleanup failures should fail the workflow instead of being
silently swallowed.

## Validation

- `/Users/ben/go/bin/actionlint
.github/workflows/pr-update-website-screenshots.yaml`
- `/Users/ben/Library/Python/3.9/bin/yamllint --config-file .yamllint
.github/workflows/pr-update-website-screenshots.yaml`
- `git diff --check HEAD~1 HEAD`

No browser e2e regression was added because this change only adjusts
GitHub Actions token permissions and label cleanup behavior; it does not
change shipped app/runtime behavior.

Fixes FE-487
2026-04-30 04:57:00 +00:00
Benjamin Lu
810381ab63 Stabilize website GitHub stars in visual snapshots (#11771)
## Summary

Stabilize the website nav GitHub star count in visual-test builds so
snapshot comparisons do not drift as the live GitHub count changes.

## Changes

- **What**: Added `WEBSITE_GITHUB_STARS_OVERRIDE` for build-time
star-count overrides and set it to `111000` in the website E2E and
screenshot-update workflows.
- **Dependencies**: None.

## Review Focus

Confirm the deterministic build-time override is preferable to
screenshot masking, since Playwright masks draw a colored rectangle
whose geometry can also drift when masked content changes size.

## Screenshots (if applicable)

Not included; this keeps visual-test input data stable rather than
changing the UI.
2026-04-29 21:57:30 -07:00
Christian Byrne
cc1a737291 test: add unit tests for useImagePreviewWidget (#11394)
## Summary
Add 23 unit tests for `useImagePreviewWidget` composable, improving
coverage from ~52% to significantly higher.

## Test Coverage
- Widget construction (factory return, name, type, serialization,
options)
- `computeLayoutSize` returns correct dimensions
- `createCopyForNode` creates properly bound copy
- Upload spinner rendering (animation, stroke color)
- Single image rendering (draws image, handles missing node.size)
- Image size text overlay (enabled/disabled via setting)
- Multi-image thumbnail grid (compact vs non-compact mode, cell borders)
- Pointer interaction (pointerDown cleanup, overIndex reset)
- `previewImages` override from DrawWidgetOptions
- `onPointerDown` drag handler setup
- `onClick` no-op behavior

## Testing
```bash
pnpm test:unit -- src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.test.ts
```
All 23 tests pass. No source changes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11394-test-add-unit-tests-for-useImagePreviewWidget-3476d73d365081a69c78e87329e75d9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-30 02:15:48 +00:00
Kelly Yang
c74e08e244 refactor: extract useBrushAdjustment from useBrushDrawing (#11544)
## Summary

Part of the `useBrushDrawing` decomposition plan (PR C). Extracts brush
size/hardness adjustment logic (Alt+drag interaction) into a dedicated
`useBrushAdjustment` composable. No runtime behavior is changed — pure
structural refactor.

## Changes

- **New** `src/composables/maskeditor/useBrushAdjustment.ts` —
encapsulates `startBrushAdjustment` and `handleBrushAdjustment`,
including dead zone filtering, dominant axis suppression, and
size/hardness clamping
- **New** `src/composables/maskeditor/useBrushAdjustment.test.ts` — unit
tests covering: no-op before start, dead zone suppression, size increase
on drag, size/hardness clamping, dominant axis lock
- **Updated** `src/composables/maskeditor/useBrushDrawing.ts` — removes
inlined adjustment state and functions, delegates to
`useBrushAdjustment(initialSettings)`

## Test Functionality
Open ComfyUI and enter the MaskEditor of any image node. On the canvas,
Alt + Right-click Drag:

- Drag Right → Increase brush size - pass
- Drag Left → Decrease brush size - pass
- Drag Up → Increase hardness - pass
- Drag Down → Decrease hardness - pass


https://github.com/user-attachments/assets/273e8383-dab5-4c82-ac7b-0a1534dfd770





<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches core pointer-interaction logic for brush tuning and changes
adjustment behavior (removes delta saturation and uses initial values),
which could subtly affect UX even though scope is localized to the mask
editor.
> 
> **Overview**
> Extracts the Alt-drag brush size/hardness adjustment logic out of
`useBrushDrawing` into a new `useBrushAdjustment` composable, and wires
`useBrushDrawing` to delegate to it.
> 
> The extracted logic now bases adjustments off the captured initial
brush size/hardness and removes prior delta capping (no ±100px
saturation), which changes how large/continuous drags affect the final
values. Adds a Vitest suite covering dead-zone behavior, dominant-axis
suppression, clamping, and the no-op-before-start contract.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b2f0ce22d3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11544-refactor-extract-useBrushAdjustment-from-useBrushDrawing-34a6d73d365081a48897dd77b04ef56b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-29 20:25:36 -04:00
Robin Huang
8f9f452c86 fix: enable Chrome password autofill on signup form (#11636)
## Summary

Add `name` attributes to the signup form's email, password, and
confirm-password inputs so Chrome's password manager recognizes the form
and offers autofill/save.

## Changes

- **What**: Pass `name` through to the underlying `<input>` on the email
field (via `pt:root:name`) and on both password fields (via
`pt:pc-input-text:root:name`). Without `name`, Chrome can't pair the
email with the password and won't surface the save-password /
suggest-strong-password prompts.

## Review Focus

- The PrimeVue passthrough syntax (`pt:root:*` for `InputText`,
`pt:pc-input-text:root:*` for `Password`) lands the attribute on the
actual `<input>` element — verified in DevTools.
- `confirm-password` is not a standard `autocomplete` token; we keep
`autocomplete="new-password"` on both password fields and only
differentiate via `name`.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/e1e25ab5-8496-4c84-b5f1-630f31956c80

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11636-fix-enable-Chrome-password-autofill-on-signup-form-34e6d73d36508180882cc9ebafb58417)
by [Unito](https://www.unito.io)
2026-04-30 00:11:20 +00:00
Benjamin Lu
9e16390c33 test: assert core command help urls (#11768)
## Summary

- Tighten the new `useCoreCommands` help command tests to assert the
exact external URL opened for GitHub issues and Discord.

## Testing

```bash
pnpm test:unit -- src/composables/useCoreCommands.test.ts
pnpm format:check src/composables/useCoreCommands.test.ts
```

Also passed pre-commit `pnpm typecheck` and push hook `pnpm knip`.

Stacked on #11748.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11768-test-assert-core-command-help-urls-3516d73d365081de99d0c71f707d0fb4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante01yoon <bunggl@naver.com>
2026-04-29 21:52:19 +00:00
Terry Jia
c88275b2a4 test(load3d): add unit tests for SceneManager (#11762)
## Summary

Add 35 unit tests for `SceneManager`, the largest remaining gap in the
load3d core (452 LOC). Targets only the logic-bearing methods
(background mode dispatch, render-mode switching, aspect-ratio scaling,
capture pipeline, dispose). Renderer-passthrough internals are
intentionally left to E2E. Follow-up to Tier 1 (#11733), Tier 2, Tier
3a, and Tier 3b.

## Changes

- **What**: 35 new tests covering construction (main scene + background
scene + grid + tiled mesh + default color mode), `toggleGrid`,
`setBackgroundColor` (color update, scene-bg cleanup, panorama-demote,
prior-texture dispose), `setBackgroundImage` (empty-path fallback,
loading-start emit, temp/output subfolder rewrite, /api prefix,
tiled-mesh material swap, panorama scene-background promotion,
prior-texture dispose, error-path fallback), `removeBackgroundImage`,
`setBackgroundRenderMode` (no-op same-mode, color-only emit, image
panorama-promote, image tiled-demote), `updateBackgroundSize`
(no-texture/no-mesh/no-map guards, wide vs. tall image scaling),
`handleResize` (image-bg active vs. color-only),
`getCurrentBackgroundInfo`, `captureScene` (returns 3 data URLs +
restores renderer state, restores grid visibility, propagates errors),
and `dispose` (resource cleanup + scene-background null).

## Review Focus

- **Coverage**: `SceneManager.ts` 89.5% lines / 74.2% branches / 89.5%
funcs. Uncovered lines are concentrated in `renderBackground` and the
deep mesh-traversal loop inside `captureScene` — exactly the
renderer-passthrough territory deferred per the Tier 3c plan.
- **`THREE.Material.needsUpdate` is a write-only setter** in THREE.js —
reading returns `undefined`. The "demote panorama → tiled" test asserts
`mat.map === texture` instead of `mat.needsUpdate === true`, with a
comment explaining why.
- **happy-dom canvas `clientWidth`/`clientHeight` default to 0** —
`makeRenderer()` overrides them via `Object.defineProperty` so
production code reading `renderer.domElement.clientWidth` gets the test
value.
- **`THREE.TextureLoader` is mocked via `vi.mock('three', ...)` with
`importOriginal`**, matching the pattern in `RecordingManager.test.ts`
and `HDRIManager.test.ts`. `mockTextureLoad` is hoisted so each test can
resolve/reject the load callback independently.
- **`vi.spyOn(manager, 'setBackgroundColor')` in three places** to
assert internal delegation (empty-path fallback, error fallback,
`removeBackgroundImage`). Defensible because the delegation IS the
documented contract for these methods.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11762-test-load3d-add-unit-tests-for-SceneManager-3516d73d365081628ff4c146defebac1)
by [Unito](https://www.unito.io)
2026-04-29 17:54:21 -04:00
pythongosssss
23e48b2140 feat: Node search - Improve category tree on mobile with collapse (#11687)
## Summary

Improves the search experience on mobile by collapsing the category menu
& reogranises the filer buttons

## Changes

- **What**: 
- add toggle button to collapse category selection
- auto collapse on mobile
- floating panel on mobile
- re-order filter buttons
- tests

## Screenshots (if applicable)
Closed:
<img width="415" height="373" alt="image"
src="https://github.com/user-attachments/assets/c99cd6cd-eb92-4ce3-9844-591dd1e80769"
/>

Desktop open:
<img width="455" height="328" alt="image"
src="https://github.com/user-attachments/assets/df15bdda-f77a-4c12-90e1-8608d67c55b4"
/>

Mobile open:
<img width="427" height="600" alt="image"
src="https://github.com/user-attachments/assets/a2b115ad-bce0-4ed1-9d30-126a35263259"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11687-feat-Node-search-Improve-category-tree-on-mobile-with-collapse-34f6d73d365081729075e8b0071a3bc1)
by [Unito](https://www.unito.io)
2026-04-29 20:52:58 +00:00
Terry Jia
af43619ae1 test(load3d): add unit tests for copyLoad3dState in load3dService (#11761)
## Summary

Add 19 unit tests for `Load3dService.copyLoad3dState` (the one method
intentionally deferred from Tier 1). Brings `load3dService.ts` from
54.5% to 100% line coverage. Follow-up to Tier 1 (#11733), Tier 2, and
Tier 3a.

## Changes

- **What**: 19 new tests covering every branch of `copyLoad3dState`:
no-source-model fast path, splat fast path (with and without
`originalURL`), mesh path (existing-target-model removal, SkeletonUtils
clone, originalModel/material/upDirection/texture copy, initial
transform on clone, gizmo transform application, gizmo enable/disable
across both source and target prior states, animation copy when
present/absent), background-image vs. background-color dispatch,
light-intensity falsy fallback, perspective-vs-orthographic FOV gating,
and the always-detach + setupForModel gizmo contract.

## Review Focus

- **Coverage**: `load3dService.ts` lines 54.5% → **100%**, branches 50%
→ **90.9%**, funcs 88.9% → **100%**. Remaining uncovered lines are minor
(`loadSkeletonUtils` cache-hit path, a couple of null-map early
returns).
- **Test fixtures use real `THREE.Object3D` and `THREE.Scene`** so
production code's `.position.set(...)`, `.rotation.set(...)`,
`scene.add/remove` calls work without further stubbing.
- **`makeTarget` memoizes the gizmo manager** (`getGizmoManager: () =>
gizmoManager` rather than returning a fresh literal each call).
Production code calls `getGizmoManager()` multiple times; without
memoization, the `detach` and `setupForModel` mocks would be
unobservable from tests.
- **`state` return on `makeTarget`** exposes mutable `modelManager`,
captured `gizmoManager`/`animationManager`, and
`sceneAdded`/`sceneRemoved` arrays so tests can assert post-state
directly without casts through the production-typed `Load3d` interface.
- **Background-image test uses `createMockLGraphNode({ id, properties
})` overrides** rather than mid-test property mutation.
- **Destructuring-default gotcha**: `const { lightsIntensity = 0.8 } =
overrides` applies the default even when `undefined` is passed
explicitly. The "fallback to setLightIntensity(1)" test passes `0`
instead — production code's `intensity || 1` short-circuits the same
way.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11761-test-load3d-add-unit-tests-for-copyLoad3dState-in-load3dService-3516d73d36508142bc72d97b27b0a36b)
by [Unito](https://www.unito.io)
2026-04-29 16:51:26 -04:00
Terry Jia
d2e88011aa test(load3d): add unit tests for EventManager, ViewHelperManager, and load3dLazy (#11760)
## Summary

Add unit tests for the three remaining small/wrapper modules in the
load3d domain (`EventManager` pub/sub, `ViewHelperManager` ViewHelper
wrapper, and `load3dLazy` lazy-extension loader). Follow-up to Tier 1
(#11733) and Tier 2.

## Changes

- **What**: 28 new unit tests across 3 files covering EventManager
add/remove/emit semantics, ViewHelperManager container DOM creation +
pointer-event interception + animation-finished camera-state emission +
perspective/orthographic zoom snapshotting + dispose ordering, and
load3dLazy extension registration + 3D-node-type recognition +
Load3D-specific `mesh_upload` injection + `beforeRegisterNodeDef` hook
replay for newly registered extensions.

## Review Focus

- **Coverage** (lines/branches/funcs): EventManager 100% / 100% / 100%,
ViewHelperManager 100% / 83.3% / 72.7%, load3dLazy 95.8% / 80% / 100%.
Aggregate: **98.6% / 85.3% / 85.7%**.
- **`vi.mock` factory side-effects only fire once per test file** —
`load3dLazy.test.ts` originally tried to count dynamic imports of
`./load3d` and `./saveMesh` via spies inside the mock factory, but
factories aren't re-invoked across `vi.resetModules()`. Switched to
verifying observable side effects (`enabledExtensions` getter call
counts, `beforeRegisterNodeDef` replay invocations).
- **Snapshot-vs-diff `enabledExtensions` queue** in
`load3dLazy.test.ts`: production code does `before = new
Set(enabledExtensions); await imports; diff =
enabledExtensions.filter(!before.has)`. To exercise the replay branch,
the mock returns `[]` first (for `before`) and `[newExtension]` second
(for the post-import snapshot) via `mockReturnValueOnce` queueing.
- **`MockViewHelper` is defined inside the `vi.mock()` factory** rather
than `vi.hoisted()`, matching the `GizmoManager.test.ts:15-41`
convention (hoisted handles only, classes inside the factory).
- **PointerEvent propagation tests** require the production code's
`event.stopPropagation()` to actually keep events from bubbling to the
parent in happy-dom; the parent listener gets attached and
asserted-not-called.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11760-test-load3d-add-unit-tests-for-EventManager-ViewHelperManager-and-load3dLazy-3516d73d3650814bb2dac3360ab0d2a1)
by [Unito](https://www.unito.io)
2026-04-29 16:50:48 -04:00
Terry Jia
180a0001e8 test(load3d): add unit tests for LightingManager, ControlsManager, exportMenuHelper, and ModelExporter (#11758)
## Summary

Add unit tests for the four Tier 2 untested logic modules in the load3d
domain (`LightingManager`, `ControlsManager`, `exportMenuHelper`, and
`ModelExporter`). Follow-up to the Tier 1 PR (#11733).

## Changes

- **What**: 43 new unit tests across 4 files covering light
setup/intensity scaling/HDRI mode/disposal, OrbitControls construction
(including DOM-parent fallback) and camera-state event emission, the
export submenu builder (item structure, submenu opening, format
dispatch, success/error toasts), and the static `ModelExporter` (URL
parsing, direct-URL fast paths for matching extensions, GLB/OBJ/STL
serialization branches, error toast paths).

## Review Focus

- **Coverage** (lines/branches/funcs): LightingManager 100% / 50% /
90.9%, ControlsManager 100% / 100% / 87.5%, exportMenuHelper 100% / 100%
/ 100%, ModelExporter 98.4% / 95.7% / 100%. Aggregate: **99.2% / 93.5% /
95.1%**.
- **`vi.mock(import('@/lib/litegraph/src/litegraph'), ...)` in
`exportMenuHelper.test.ts`** uses the dynamic-import form so
`importOriginal()` is auto-typed. Required because `apiSchema.ts`
transitively imports `LinkMarkerShape` from the same module — replacing
the whole module breaks the build. The mock replaces only
`LiteGraph.ContextMenu` in-place on the real singleton.
- **`MockContextMenu` must be a class**, not an arrow function —
production code does `new LiteGraph.ContextMenu(...)`. Initial
arrow-function mock failed with "is not a constructor".
- **Fake-timer rejection pattern in `ModelExporter.test.ts`**: rejection
assertions are attached *before* `vi.runAllTimersAsync()` (`const
assertion = expect(p).rejects.toThrow(...); await drain; await
assertion`) to avoid unhandled-rejection warnings.
- **Surprising `detectFormatFromURL` behavior**:
`detectFormatFromURL('?filename=cube')` returns `'cube'`, not `null`,
because `'cube'.split('.').pop()` returns the whole basename when no dot
is present. Test documents this rather than asserting an incorrect
expectation.
- **Two unreachable lines left uncovered**: `LightingManager:65` (`?? 1`
fallback in the `setLightIntensity` multiplier lookup — every light is
registered in the map at construction, so the fallback is dead) and
`ModelExporter:21` (a `try/catch` around `URLSearchParams` whose
constructor cannot throw on the inputs the production code passes).

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11758-test-load3d-add-unit-tests-for-LightingManager-ControlsManager-exportMenuHelper-and-3516d73d365081cb96fff33672503822)
by [Unito](https://www.unito.io)
2026-04-29 16:50:06 -04:00
Dante
8f011225bf test: add unit tests for useCoreCommands canvas/help commands (#11748)
## Summary

Adds 8 tests across three new describe blocks for
\`src/composables/useCoreCommands.ts\`:

- **Canvas view**: \`Comfy.Canvas.ResetView\`, \`Comfy.Canvas.ZoomIn\`,
\`Comfy.Canvas.ZoomOut\`.
- **Workflow lifecycle**: \`Comfy.OpenClipspace\`,
\`Comfy.RefreshNodeDefinitions\`.
- **Help**: \`Comfy.Help.OpenComfyUIIssues\`,
\`Comfy.Help.OpenComfyOrgDiscord\`, \`Comfy.Help.AboutComfyUI\`.

Adds \`vi.hoisted\` mocks for \`useTelemetry\`, \`useSettingsDialog\`,
and \`useLitegraphService.resetView\` so they remain isolated from the
existing 15-test suite.

## Why this slice

\`useCoreCommands.ts\` exports 118 distinct command callbacks (1356
LOC). A single coverage-backfill PR for the whole file would be unwieldy
and risk merge conflicts (this file is touched frequently). This PR
covers a coherent slice — view/lifecycle/help commands — and follow-up
PRs can pick off remaining clusters.

## Testing

\`\`\`bash
pnpm vitest run src/composables/useCoreCommands.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11748-test-add-unit-tests-for-useCoreCommands-canvas-help-commands-3516d73d365081c384ffcc72c15dfd47)
by [Unito](https://www.unito.io)
2026-04-29 13:23:07 -07:00
pythongosssss
3c50487c18 test: add test for MaxHistoryItems setting (#11750)
## Summary

Adds test to ensure MaxHistoryItems limits both API query & client
rendering

## Changes

- **What**: 
- ensure limit is added to url
- ensure virtual grid is capped to limit

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11750-test-add-test-for-MaxHistoryItems-setting-3516d73d365081daa973ccfd8a7f479e)
by [Unito](https://www.unito.io)
2026-04-29 19:10:31 +00:00
pythongosssss
818e549e8e fix: hide blueprint node id in search (#11759)
There is a setting that enables Node IDs to display on the search
results.
Subgraphs have long non-user friendly IDs which cause this to render
badly for the built in blueprints (and user published). This update
hides the IDs for blueprint nodes.

## Changes

- **What**: 
- hide if blueprint
- add test

## Screenshots (if applicable)

Before:
<img width="910" height="504" alt="image"
src="https://github.com/user-attachments/assets/9eea9fd7-8f72-4e1b-9522-46efba0ef71a"
/>

After:
<img width="797" height="552" alt="image"
src="https://github.com/user-attachments/assets/43d6fc62-4102-41c3-b9bb-a3efd244580d"
/>

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11759-fix-hide-blueprint-node-id-in-search-3516d73d365081baa055d12c6a31fadd)
by [Unito](https://www.unito.io)
2026-04-29 17:08:54 +00:00
AustinMroz
fd1a8e9432 Fix legacy widget width in app mode (#11574)
When in app mode, widgets can be drawn with size different from the size
of the parent node. Mouse events on legacy canvas widgets require that
the client code query the current state of the node and widget to
determine if any elements are being interacted with. This PR sets the
`widget.width` property when a legacy canvas widget draw operation
occurs so that custom nodes can properly resolve subsequent mouse
events.

At current, no core nodes exist that utilize legacy widgets. As a result
the setup code to test this bug fix is slightly involved.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11574-Fix-legacy-widget-width-in-app-mode-34b6d73d365081caaa34c6204f8361f6)
by [Unito](https://www.unito.io)
2026-04-29 09:31:57 -07:00
Dante
8f61ecd82e test: add unit tests for executionStore WebSocket handlers (#11746)
## Summary

Adds 22 new tests for \`src/stores/executionStore.ts\`, raising coverage
from **48.7% → 82.9%** lines (functions 47% → 72.5%). Tests drive each
WebSocket handler by capturing handlers registered through the mocked
\`api.addEventListener\` and dispatching CustomEvents at them.

## Test Coverage

WebSocket handlers (driven through \`bindExecutionEvents\`):
- \`execution_start\` — sets activeJobId, seeds queued job entry, clears
initializing state for the starting job.
- \`execution_cached\` — marks listed nodes; no-op when no active job.
- \`execution_interrupted\` — clears active job state.
- \`executed\` — marks executed node; no-op when no active job.
- \`execution_success\` — clears active job and progress state.
- \`executing\` — clears \`_executingNodeProgress\` and activeJobId on
null detail.
- \`progress\` — sets \`_executingNodeProgress\`.
- \`status\` — reads clientId once and stops listening.
- \`execution_error\` — service-level (no node_id) routes to
\`lastPromptError\`; runtime errors route to \`lastExecutionError\`.
- \`notification\` — marks job as initializing on "Waiting for a
machine"; ignores empty id and unrelated text.

Other:
- \`unbindExecutionEvents\` removes every listener registered.
- \`storeJob\` populates queuedJobs, jobIdToWorkflowId,
jobIdToSessionWorkflowPath.
- \`registerJobWorkflowIdMapping\` ignores empty inputs.
- \`ensureSessionWorkflowPath\` is idempotent and updates on change.

## Testing

\`\`\`bash
pnpm vitest run src/stores/executionStore.test.ts
pnpm vitest run src/stores/executionStore.test.ts --coverage
--coverage.include='src/stores/executionStore.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11746-test-add-unit-tests-for-executionStore-WebSocket-handlers-3516d73d3650810aa910f5a022fdc17b)
by [Unito](https://www.unito.io)
2026-04-29 08:29:59 -04:00
Dante
8fe0385a57 test: add unit tests for pnginfo wrappers and getLatentMetadata (#11745)
## Summary

Extends \`src/scripts/pnginfo.test.ts\` with 5 new tests covering the
format-specific delegating wrappers and the safetensors metadata reader.
Lifts pnginfo.ts coverage from **17.6% → 23.2%** lines (the remaining
gap is \`importA1111\`, which needs a refactor before it can be tested
cleanly — left to a follow-up).

## Test Coverage

- \`getPngMetadata\`, \`getFlacMetadata\`, \`getAvifMetadata\` delegate
to their respective \`metadata/*\` modules (mocked).
- \`getLatentMetadata\` returns the \`__metadata__\` object from a
hand-built safetensors header.
- \`getLatentMetadata\` resolves \`undefined\` when the header has no
\`__metadata__\` entry.

## Out of scope

\`importA1111\` (lines 176-542) is a 270-line A1111-prompt →
ComfyUI-graph builder. Testing it requires either heavy LiteGraph mocks
or a refactor that extracts pure parsing helpers from the graph-mutation
code. Tracking separately.

## Testing

\`\`\`bash
pnpm vitest run src/scripts/pnginfo.test.ts
pnpm vitest run src/scripts/pnginfo.test.ts --coverage
--coverage.include='src/scripts/pnginfo.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11745-test-add-unit-tests-for-pnginfo-wrappers-and-getLatentMetadata-3516d73d365081c080a6c8146aa1bee8)
by [Unito](https://www.unito.io)
2026-04-29 08:25:26 -04:00
Dante
d078af3a79 test: add unit tests for avif metadata parser (#11744)
## Summary

Adds 12 tests for `src/scripts/metadata/avif.ts`, raising line coverage
from **2.3% → 90.4%** (statements 88.5%, functions 93.3%).

## Test Coverage

Happy paths:
- Workflow JSON extracted from EXIF Exif item (LE)
- Prompt JSON extracted
- Big-endian (MM) EXIF parsing
- Both prompt and workflow present in separate EXIF entries

Negative paths (each yields `{}` without throwing):
- AVIF major brand is not "avif"
- Meta box missing
- iinf has no Exif item
- EXIF entry uses an unrecognized key
- EXIF entry has malformed JSON
- infe version is unsupported (1)
- iloc box missing while iinf has Exif
- Buffer too short for valid header

## Testing

\`\`\`bash
pnpm vitest run src/scripts/metadata/avif.test.ts
pnpm vitest run src/scripts/metadata/avif.test.ts --coverage
--coverage.include='src/scripts/metadata/avif.ts'
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11744-test-add-unit-tests-for-avif-metadata-parser-3516d73d365081c5b29adf7a2b9eff62)
by [Unito](https://www.unito.io)
2026-04-29 08:21:55 -04:00
Terry Jia
57d708767a test(load3d): add unit tests for AnimationManager, CameraManager, RecordingManager, and load3dService (#11733)
## Summary

Add unit tests for the four largest untested logic-heavy modules in the
load3d domain (`AnimationManager`, `CameraManager`, `RecordingManager`,
and the `load3dService` façade).

## Changes

- **What**: 78 new unit tests across 4 files covering animation
lifecycle (mixer setup, clip switching, play/pause/seek, dispose),
camera state (perspective↔orthographic toggling, FOV gating, state
round-trip, controls rebinding, resize math, model fitting), recording
lifecycle (MediaRecorder wiring, indicator visibility, chunk handling,
export/clear paths, dispose ordering), and the service singleton
(sync/async map access, viewer cache, `handleViewerClose` apply-changes
flow, `handleViewportRefresh` camera-toggle dance).

## Review Focus

- **Coverage**: AnimationManager 100%, CameraManager 88.8%,
RecordingManager 89.1%, load3dService 54.5% lines / 88.9% functions. The
service gap is concentrated in one method — `copyLoad3dState` (lines
217-333) — which is entangled with 3-4 subsystems and is intentionally
deferred to a follow-up PR.
- **happy-dom shims** in `RecordingManager.test.ts`: `MediaRecorder`,
`HTMLCanvasElement.prototype.captureStream`, and `getContext('2d')` are
all stubbed because happy-dom doesn't provide them.
`THREE.TextureLoader` is also mocked because the constructor eagerly
loads an SVG indicator.
- **Singleton state reset** in `load3dService.test.ts`: the service
holds a module-level `viewerInstances` Map that can't be reached from
outside. Tests track every node they create in a `Set` and drain via
`removeViewer` in `beforeEach`. Cleaner than `vi.resetModules()` (which
would re-import the service and break the singleton identity).
- **One subtle THREE behavior**: `AnimationManager.setAnimationTime`
clamps to `duration`, but `AnimationMixer.setTime(duration)` with
`LoopRepeat` wraps `action.time` back to 0. The clamping is therefore
only observable through the emitted progress event, not via
`action.time` directly — the test asserts via the event.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11733-test-load3d-add-unit-tests-for-AnimationManager-CameraManager-RecordingManager-and--3516d73d3650812485a0c91065e161f0)
by [Unito](https://www.unito.io)
2026-04-29 08:14:30 -04:00
Benjamin Lu
b3f5f82216 Remove unused queue job components (#11621)
## Summary
Remove the unused legacy queue job row implementation before changing
the live queue popover behavior.

## Changes
- Deleted `JobGroupsList` and its test.
- Deleted `QueueJobItem` and its Storybook story.
- Deleted `QueueAssetPreview`, which was only used by `QueueJobItem`.

## Review Focus
- This PR is deletion-only.
- `rg` found no remaining references to these components.
- `knip` and `typecheck` pass.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11621-Remove-unused-queue-job-components-34d6d73d36508164bf32cb581594cd9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-29 04:55:10 +00:00
Dante
9df4e02189 refactor: unify media asset downloads (#11717)
## Summary

Unifies media asset download actions behind a single
`downloadAssets(assets?)` API to avoid single and multi asset download
path drift.

## Changes

- **What**: Replaces `downloadAsset` and `downloadMultipleAssets` with
`downloadAssets`, preserving no-arg media context fallback and explicit
asset arrays.
- **Dependencies**: None.

## Review Focus

Download behavior for single-card, context-menu, and sidebar bulk
actions should continue to use the same ZIP-export path for cloud
multi-output jobs.

Fixes #11715

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11717-refactor-unify-media-asset-downloads-3506d73d3650810d8bcec9c0194e743d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-29 04:50:45 +00:00
AustinMroz
1c541d8577 Short circuit asset reuploads, simplify node dnd (#11691)
When an output is dragged from the assets panel onto a node, outputs
were being reuploaded. This logic has been simplified to instead
reference the existing asset by resolving the annotated path.

As part of this change, async drop handlers on nodes are also fixed.
Rather than placing obligation of event handling on client code, not
respecting async handlers, or completely ignoring return types, the vue
drop handler will now simply set `app.dragOverNode` and allow the
`document` drop handler to resolve node drag/drop operations without any
of the difficulty from propagation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11691-Short-circuit-asset-reuploads-simplify-node-dnd-34f6d73d36508157af86e6cf09229781)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-28 18:49:35 -07:00
Comfy Org PR Bot
389ff8ba49 1.44.13 (#11732)
Patch version increment to 1.44.13

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11732-1-44-13-3516d73d36508119acabf5f86256f2aa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-29 01:23:53 +00:00
Terry Jia
bb74ec94de test(load3d): add unit tests for 9 previously-untested controls (#11730)
## Summary
Mirror the maskeditor coverage approach for load3d sub-components. Each
component gets behavior tests covering rendering, conditional branches,
v-model bidirectional sync, and emitted events.

- ViewerLightControls: setting-store min/max/step + v-model
- ViewerExportControls: format dropdown + click-to-export
- ViewerCameraControls: type select, FOV slider visibility
- ViewerSceneControls: with/without bg image branches, render mode
- PopupSlider: trigger toggle, click-outside dismissal, defaults
- CameraControls: switch button, FOV PopupSlider visibility
- ExportControls: trigger popup, format selection, click-outside
- AnimationControls: empty-list bypass, controls, time formatting
- ViewerControls: dialog open routing + onClose wiring

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11730-test-load3d-add-unit-tests-for-9-previously-untested-controls-3506d73d365081eaa9e7c5d0b922fc14)
by [Unito](https://www.unito.io)
2026-04-28 19:23:09 -04:00
Kelly Yang
e7640d414b test: add E2E tests for ActionBarButtons toolbar component (#11561)
## Summary

- Add E2E tests for the `ActionBarButtons` toolbar component (FE-111)
- Add `data-testid="action-bar-buttons"` to the container div for stable
test targeting
- Register `TestIds.topbar.actionBarButtons` in `selectors.ts`

## Changes

- `browser_tests/tests/actionBarButtons.spec.ts` — 6 tests across 5
scenarios: empty state, button rendering, icon rendering, multiple
buttons, click handler, mobile label hiding
- `src/components/topbar/ActionBarButtons.vue` — adds `data-testid` to
container
- `browser_tests/fixtures/selectors.ts` — registers new test ID

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Primarily adds Playwright coverage and a `data-testid` attribute;
runtime behavior is unchanged aside from an extra DOM attribute.
> 
> **Overview**
> Adds a new Playwright spec (`actionBarButtons.spec.ts`) that verifies
the ActionBarButtons container empty state, rendering (label/icon),
multiple buttons, click handler execution, and mobile label-hiding
behavior by registering buttons via `window.app!.registerExtension`.
> 
> Updates the UI and test selector plumbing by adding
`data-testid="action-bar-buttons"` to `ActionBarButtons.vue` and
exposing it as `TestIds.topbar.actionBarButtons` for stable E2E
targeting.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
80f90d1f1d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11561-test-add-E2E-tests-for-ActionBarButtons-toolbar-component-34b6d73d36508153874fda856a78817f)
by [Unito](https://www.unito.io)
2026-04-28 19:13:12 -04:00
Alexander Brown
c168c37c94 chore: update comfyui-ci-container to 0.0.17 (#11569)
Update `comfyui-ci-container` image to `0.0.17` across all CI workflows.

| Workflow | Before | After |
|---|---|---|
| `ci-perf-report.yaml` | `0.0.12` | `0.0.17` |
| `ci-tests-e2e.yaml` (×2) | `0.0.16` | `0.0.17` |
| `pr-update-playwright-expectations.yaml` | `0.0.16` | `0.0.17` |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11569-chore-update-comfyui-ci-container-to-0-0-17-34b6d73d365081b8a52ac995855354cb)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 22:05:28 +00:00
pythongosssss
089051824c test: add tests for link related settings (#11612)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11612-test-add-tests-for-link-related-settings-34c6d73d36508145885bd017162e6fae)
by [Unito](https://www.unito.io)
2026-04-28 22:02:42 +00:00
pythongosssss
517da289f6 feat: Search - add ghost node following setting and increase opacity (#11365)
## Summary

Adds setting to disable the node auto-follow cursor behavior when adding
nodes from the search, and increased the visibilty of Vue ghost nodes.

## Changes

- **What**: 
- add setting
- increase opacity
- add test

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

Before  
<img width="452" height="517" alt="image"
src="https://github.com/user-attachments/assets/369c0d90-5352-482b-a1b3-36180bffb3ee"
/>

After  
<img width="440" height="536" alt="image"
src="https://github.com/user-attachments/assets/2066fdd4-6eb4-4bfb-ac7c-559fc99de57d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11365-feat-Search-add-ghost-node-following-setting-and-increase-opacity-3466d73d3650811b9c27ed4cc930816d)
by [Unito](https://www.unito.io)
2026-04-28 22:02:33 +00:00
Dante
98c327b3c6 test: add unit tests for colorUtil edge cases (#11671)
## Summary

Extends `colorUtil.test.ts` with boundary tests that the existing suite
did not cover: malformed `parseToRgb` inputs, alpha-hex parsing through
`parseToRgb`, negative hue normalization in `hsbToRgb`, the full
`isTransparent` matrix, HSV-vs-HSB equivalence in `toHexFromFormat`, the
bare-hex prefix path, and a non-primary-color round-trip through
`hexToHsva` / `hsvaToHex`.

## Changes

- **What**: Adds 13 Vitest cases across 5 new `describe` blocks
(`parseToRgb edge cases`, `hsbToRgb normalization`, `isTransparent`,
`toHexFromFormat`, plus a non-primary round-trip in the existing
`hexToHsva / hsvaToHex` block). Uses the existing
`vi.mock('es-toolkit/compat')` memoize stub.

## Review Focus

- The non-primary palette round-trip allows ±1 per RGB channel because
`hsbToRgb` floors while `rgbToHex` rounds; the test asserts the bound
rather than exact equality.
- `parseToRgb` is exercised with alpha-bearing hex (`#f008`,
`#ff000080`); the function returns RGB-only, so the alpha is
intentionally discarded.
- `toHexFromFormat({h, s, v}, 'hsb')` covers the HSV-shaped object path
that wraps `hsbToRgb`.

## Testing

\`\`\`bash
pnpm exec vitest run src/utils/colorUtil.test.ts
pnpm format -- src/utils/colorUtil.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

73 tests pass (60 prior + 13 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11671-test-add-unit-tests-for-colorUtil-edge-cases-34f6d73d36508136bac9edfae32815ec)
by [Unito](https://www.unito.io)
2026-04-28 16:50:20 -04:00
Terry Jia
fc2a4e82cf feat(load3d): bind UI capability gating to ModelAdapterCapabilities (#11711)
> Final piece of the PLY / 3D Gaussian Splatting series. Previous PR
made `ModelAdapterCapabilities` load-bearing on the engine side; the UI
was still gating off `isSplatModel` / `isPlyModel` proxies. This PR
routes the viewer and the viewer-mode dialog through the capability
fields directly, so the same source of truth that drives `Load3d`
behavior also drives what the user sees. Eighth and last in the series
splitting up.

## Summary

Snapshot `Load3d.getCurrentModelCapabilities()` into 5 Vue refs on each
model load and pipe them through the existing `Load3D` /
`Load3DControls` / `Load3dViewerContent` / `ModelControls` /
`ViewerModelControls` / `Preview3d` props. Replaces the format-specific
`:is-splat-model` / `:is-ply-model` props and the hardcoded "splat →
drop light/gizmo/export" subtraction with additive capability gates. No
engine behavior changes — capability values are what previous PR already
produces; UI now consumes them.

## Changes

- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: 5 new refs
(`canFitToViewer` / `canUseGizmo` / `canUseLighting` / `canExport` /
`materialModes`) refreshed on every load via
`load3d.getCurrentModelCapabilities()`. `useLoad3dViewer` extracts the
snapshot into a single `captureAdapterFlags(source)` helper because it
runs in three places (initializeViewer / initializeStandaloneViewer /
loadStandaloneModel).
- **`Load3D.vue`**: gate the fit-to-viewer button on `canFitToViewer`;
pass capability refs to `Load3DControls` instead of `isSplatModel` /
`isPlyModel`.
- **`Load3DControls.vue`**: build `availableCategories` additively
(`['scene','model','camera']` plus `light` / `gizmo` / `export` if their
capability is true) rather than subtracting from a fixed list when
`isSplatModel` is true. Forwards `materialModes` to `ModelControls`.
- **`Load3dViewerContent.vue`**: gate the light / gizmo / export sidebar
sections on the capability refs; pass `materialModes` to
`ViewerModelControls`.
- **`ModelControls.vue` / `ViewerModelControls.vue`**: drop the local
`materialModes` computed (which derived its options from `isPlyModel`
and a hardcoded mesh list) and accept `materialModes` as a `readonly
MaterialMode[]` prop. An empty array hides the dropdown entirely.
- **`Preview3d.vue`** (renderer linearMode): mirror the prop swap on the
standalone preview path.

## Review Focus

- **Capability prop wiring is the only public-API change for child
components**. `ModelControls` and `ViewerModelControls` lost
`hideMaterialMode` / `isPlyModel` props. Any extension that imported
these components directly will need to migrate, but they're internal
`src/components/load3d/controls/**` files and not part of the documented
extension surface.
- **Empty-`materialModes` semantics**: previously hidden via
`:hide-material-mode`; now hidden via `materialModes.length === 0`.
`SplatModelAdapter` declares `materialModes: []`, so the splat case
keeps the same behavior — the dropdown disappears. PLY adds
`'pointCloud'` to the array, so the dropdown picks up that mode
automatically without the controls needing an `isPlyModel` branch.
- **`captureAdapterFlags` runs after every load completes**, so
switching between mesh and splat in the same viewer instance updates the
chrome correctly. Verified via the new `Load3D.test.ts` /
`Load3dViewerContent.test.ts` cases.
- **Capability gating is inclusive of `canFitToViewer`** in this PR even
though `Load3DControls` has no fit category — the fit-to-viewer floating
button on `Load3D.vue` is what reads it. PLY's `fitToViewer: true` means
the button stays visible for PLY users.

## Coverage

| File | Stmts | Branch | Funcs |
|---|---|---|---|
| `Load3D.vue` (modified) | 53.3% | **95.5%** | 83.3% |
| `Load3DControls.vue` (modified) | 77.5% | **94.8%** | 86.4% |
| `Load3dViewerContent.vue` (modified) | 60.6% | 72.1% | 54.5% |
| `controls/ModelControls.vue` (modified) | 16.3% | 0% | 0% |
| `controls/viewer/ViewerModelControls.vue` (modified) | **100%** |
**100%** | **100%** |
| `composables/useLoad3d.ts` (modified) | 78.7% | 64.5% | 71.4% |
| `composables/useLoad3dViewer.ts` (modified) | 76.0% | 52.1% | 66.7% |

Four new test files (`Load3D.test.ts` / `Load3DControls.test.ts` /
`Load3dViewerContent.test.ts` /
`controls/viewer/ViewerModelControls.test.ts`) cover the new capability
gating directly: each component is rendered with capability flags
toggled on/off and the appropriate sidebar / dropdown / button
visibility is asserted. Capability prop forwarding from `Load3D.vue` →
`Load3DControls.vue` and from `Load3dViewerContent.vue` →
`ViewerModelControls.vue` is exercised end-to-end.

`controls/ModelControls.vue` is the legacy node-side ModelControls — its
existing tests live elsewhere and were not in this PR's scope; the diff
line covered (the `v-if="materialModes.length > 0"` swap) is exercised
by the new `Load3DControls.test.ts` cases that drive a non-empty / empty
`materialModes` through. `Preview3d.vue` (renderer linearMode) has no
test file in the project; the prop swap there is the same shape as the
`Load3D.vue` swap which is covered.

`useLoad3d.ts` / `useLoad3dViewer.ts` percentages are roughly the
pre-existing baseline. The diff lines (the 5 new refs and the
`captureAdapterFlags` helper) are exercised by the existing composable
tests via the mock that now stubs `getCurrentModelCapabilities()`.

73 new component unit tests; 393 total load3d-related tests pass on this
branch.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11711-feat-load3d-bind-UI-capability-gating-to-ModelAdapterCapabilities-3506d73d365081b3af68f30e3f728e24)
by [Unito](https://www.unito.io)
2026-04-28 16:39:06 -04:00
pythongosssss
e48d33e4c0 test: Canvas grid, ctx menu scaling and group padding settings (#11721)
## Summary

Adds tests for canvas snap to grid, context menu scaling and group
padding

## Changes

- **What**: 
- add tests for canvas related settings

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11721-test-Canvas-grid-ctx-menu-scaling-and-group-padding-settings-3506d73d36508141868cfa990d903c33)
by [Unito](https://www.unito.io)
2026-04-28 17:21:55 +00:00
pythongosssss
967f1eb562 test: extract title editor test component (#11605)
## Summary

Extract shared TitleEditor component and update tests to use it

## Changes

- **What**: 
- add title editor helper
- update locations that used `TestIds.node.titleInput`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11605-test-extract-title-editor-test-component-34c6d73d3650811da6b0ec493b190c3f)
by [Unito](https://www.unito.io)
2026-04-28 09:46:25 -04:00
Kelly Yang
8b83559402 refactor: extract useBrushPersistence from useBrushDrawing (#11543)
## Summary

PR B of this https://github.com/Comfy-Org/ComfyUI_frontend/pull/11388
Part of the `useBrushDrawing` decomposition plan (PR B). 
Extracts brush settings persistence logic into a dedicated
`useBrushPersistence` composable, reducing the responsibility surface of
`useBrushDrawing`.

No runtime behavior is changed — this is a pure structural refactor.

## Changes

- **New** `src/composables/maskeditor/useBrushPersistence.ts` —
encapsulates `loadAndApply` (reads brush settings from localStorage and
applies them to the store on init) and `save` (debounced write of
current brush settings to localStorage)
- **New** `src/composables/maskeditor/useBrushPersistence.test.ts` —
unit tests covering load from empty storage, full restore round-trip,
missing `stepSize` fallback, corrupted data resilience, and
save-to-localStorage behavior
- **Updated** `src/composables/maskeditor/useBrushDrawing.ts` — removes
the inlined persistence functions and delegates to `useBrushPersistence`

## Test Locally
1. Adjust brush size and hardness, close MaskEditor, then reopen it —
brush parameters should restore to the previous settings (verifies
`save` + `loadAndApply`) - pass
2. Draw a few strokes and confirm the marks appear correctly (verifies
the `saveBrushSettings` public interface is not broken) - pass


https://github.com/user-attachments/assets/961155d5-6742-4668-a419-51c29b850edf





<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk refactor that moves localStorage read/write logic behind a
new composable and adds unit tests; main risk is accidental behavior
drift in when/what brush settings are persisted/restored.
> 
> **Overview**
> Refactors mask editor brush settings persistence by extracting the
localStorage load/save (including debounced writes and `stepSize`
fallback) out of `useBrushDrawing` into a new `useBrushPersistence`
composable, while keeping the `saveBrushSettings` public API wired
through.
> 
> Adds `useBrushPersistence` unit tests covering empty storage,
round-trip restore, missing field defaults, corrupted JSON handling, and
save semantics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d87d7c8bcf. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11543-refactor-extract-useBrushPersistence-from-useBrushDrawing-34a6d73d36508144b7edff759a1e3485)
by [Unito](https://www.unito.io)
2026-04-28 09:15:39 -04:00
Kelly Yang
bc11b5ff5e test: add E2E tests for LoginButton toolbar component (#11562)
## Summary

- Add E2E tests for the `LoginButton` toolbar component (FE-109)
- Add `data-testid="login-button"` to `LoginButton.vue` for stable
targeting
- Register `TestIds.topbar.loginButton` in `selectors.ts`

## Changes

- `browser_tests/tests/loginButton.spec.ts` — 7 tests covering:
visibility toggled by `show_signin_button` server feature flag, ARIA
label, click → sign-in dialog, hover popover content, popover dismissal
- `src/components/topbar/LoginButton.vue` — adds `data-testid`
- `browser_tests/fixtures/selectors.ts` — registers new test ID

## Notes

`LoginButton` is rendered in `WorkflowTabs` when `flags.showSignInButton
?? isDesktop` is true. Since `isDesktop = false` in the OSS test build
(`DISTRIBUTION=localhost`), tests enable the button by setting
`window.app.api.serverFeatureFlags.value.show_signin_button = true` —
the established pattern used throughout the test suite (e.g.
`nodeLibraryEssentials.spec.ts`, `shareWorkflowDialog.spec.ts`).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to adding stable `data-testid` hooks
plus new Playwright E2E coverage, with only a minor adjustment to a
unit-test performance threshold.
> 
> **Overview**
> Adds Playwright E2E coverage for the topbar `LoginButton`, including
feature-flag-driven visibility, ARIA labeling, click-to-open sign-in
dialog, and hover popover behavior (including the *Learn more* link and
dismissal).
> 
> To make the tests stable, `LoginButton.vue` now exposes `data-testid`
attributes for the button and popover elements, and `selectors.ts`
registers new `TestIds.topbar.*` entries.
> 
> Relaxes the `useModelToNodeStore.getCategoryForNodeType` performance
test threshold (from 10ms to 100ms) while clarifying the intent of the
check.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1d4dd0bdca. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11562-test-add-E2E-tests-for-LoginButton-toolbar-component-34b6d73d365081f1bf2edc747d69ee52)
by [Unito](https://www.unito.io)
2026-04-28 08:33:42 -04:00
Dante
8c1ea7ae64 test: add unit tests for useNodePricing edge cases (#11673)
## Summary

Extends `useNodePricing.test.ts` with three behavioral gaps the existing
suite did not cover: `getNodePricingConfig` does not leak the compiled
JSONata expression, `pricingRevision` ticks after async evaluation
resolves (with a cache-hit path that does not), and
`formatPricingResult` returns `''` for non-finite numeric inputs across
all four result types.

## Changes

- **What**: Adds 9 Vitest cases across two existing `describe` blocks
(`getNodePricingConfig`, `formatPricingResult`) and one new block
(`reactive revision`). Reuses the existing `priceBadge` and
`createMockNodeWithPriceBadge` helpers.

## Review Focus

- The cache-hit assertion checks that `pricingRevision.value` does not
advance after a second `getNodeDisplayPrice` call with the same
signature, exercising the WeakMap cache hit at `useNodePricing.ts:573`.
- Non-finite coverage spans `type:'usd'`, `type:'range_usd'`,
`type:'list_usd'` (empty + all-non-finite + mixed), and the legacy `{
usd }` shape, matching the four `asFiniteNumber` call sites in
`formatPricingResult`.
- The strip-`_compiled` assertion uses `toHaveProperty` so the test
fails loudly if a future refactor accidentally re-exposes the runtime
JSONata instance to debug consumers.

## Testing

\`\`\`bash
pnpm exec vitest run src/composables/node/useNodePricing.test.ts
pnpm format -- src/composables/node/useNodePricing.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

91 tests pass (82 prior + 9 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11673-test-add-unit-tests-for-useNodePricing-edge-cases-34f6d73d365081dab525cdaa71b348a5)
by [Unito](https://www.unito.io)
2026-04-28 04:15:34 -07:00
Dante
69e68847d9 test: add unit tests for workspaceAuthStore retry/race paths (#11674)
## Summary

Extends `useWorkspaceAuth.test.ts` with the retry, race,
storage-resilience, and Zod-validation paths the existing suite did not
exercise: exponential backoff on `TOKEN_EXCHANGE_FAILED`, immediate
context clearing on permanent errors, stale-refresh abort when the user
switches workspaces mid-flight, and resilience to `sessionStorage` quota
errors.

## Changes

- **What**: Adds 6 Vitest cases across three new `describe` blocks
(`refreshToken retry/race paths`, `persistToSession resilience`, `Zod
validation on token response`). Reuses the existing `mockGetIdToken`,
`mockTeamWorkspacesEnabled`, `vi.useFakeTimers()`, and
`mockTokenResponse` fixtures.

## Review Focus

- The retry test uses `vi.runAllTimersAsync()` so the three backoff
sleeps (1s + 2s + 4s) drain in a single tick instead of slowing the
suite. Both `console.warn` (per-attempt) and `console.error`
(final-failure) are silenced.
- The race test resolves the in-flight refresh fetch with a token tied
to the OLD workspace AFTER `switchWorkspace('workspace-other')` has run,
so the assertion fails loudly if the stale-request guard regresses.
- The sessionStorage spy targets the instance method
(`vi.spyOn(sessionStorage, 'setItem')`); spying
`Storage.prototype.setItem` does not intercept happy-dom's per-instance
method.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/stores/useWorkspaceAuth.test.ts
pnpm format -- src/platform/workspace/stores/useWorkspaceAuth.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

33 tests pass (27 prior + 6 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11674-test-add-unit-tests-for-workspaceAuthStore-retry-race-paths-34f6d73d365081e6a8ecce59a156585e)
by [Unito](https://www.unito.io)
2026-04-28 02:56:38 -07:00
comfydesigner
fad9cf0db7 fix: consolidate --color-coral-red variables into --color-coral (#10374)
Removes the desktop-exclusive `--color-coral-red` CSS variables and
replaces their usage with the shared `--color-coral` palette to reduce
variable duplication in the design system.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10374-fix-consolidate-color-coral-red-variables-into-color-coral-32a6d73d365081a4ac88d0ea96aeea02)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alex <alex@Mac.lan>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-04-28 09:17:48 +00:00
pythongosssss
d532fcf779 test: add tests for canvas related settings (#11604)
## Summary

Adds test coverage for canvas related settings

## Changes

- **What**: 
- tests canvas info visibility, fps, pointer modes, selection

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11604-test-add-tests-for-canvas-related-settings-34c6d73d365081748b0ec79bc6fdc6ca)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 08:52:49 +00:00
pythongosssss
52e73f2697 test: Expand node search box V2 e2e coverage (#10620)
## Summary

Adds additional browser test coverage to the v2 node search

## Changes

- **What**:  
- extend search v2 fixtures
   - add additional tests
   - add data-testid for targeting elements
   - rework tests to work with new menu UI

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10620-test-Expand-node-search-box-V2-e2e-coverage-3306d73d365081b6bad3f73daab1194f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-28 08:52:28 +00:00
Simon Pinfold
c4043637d6 fix: route context menu Download through downloadMultipleAssets (#11700)
*PR Created by the Glary-Bot Agent*

---

## Summary

The asset context menu's Download action called `downloadAsset`
directly, so multi-output jobs only downloaded the preview file instead
of all outputs. Route through `downloadMultipleAssets`, which detects
multi-output jobs and creates a ZIP export in cloud mode and falls back
to the single-download path otherwise.

## Changes

- **What**: Swap `actions.downloadAsset(asset)` for
`actions.downloadMultipleAssets([asset])` in the per-asset context menu
Download command, and extend the existing unit test to assert the
routing.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

For single-output assets the behavior is unchanged:
`downloadMultipleAssets([asset])` falls through to the
individual-download path when `hasMultiOutputJobs` is false and
`assets.length === 1` (see `useMediaAssetActions.ts:106`). Verified
manually — right-clicking a single-output asset and clicking Download
still produces one file download to the correct `/api/view` URL.

## Notes

This is a focused replacement for the stale #10948. Compared to that
branch:
- Drops the unrelated `bootstrapStore` API-key auth changes (scope
creep).
- Drops the new `assets.cloud.spec.ts` Playwright spec — cloud
asset-export E2E coverage was added in #11610
(`browser_tests/tests/sidebar/assets.spec.ts`), so a separate cloud spec
for this routing change would mostly duplicate it.
- Keeps the unit-test change minimal: extends the existing `ContextMenu`
stub with a `model` prop watcher and adds one new test, rather than
rewriting the whole file from `@testing-library/vue` to
`@vue/test-utils`.

## Verification

- `pnpm test:unit` (MediaAssetContextMenu.test.ts and
useMediaAssetActions.test.ts)
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format` / `oxfmt --check`
- `pnpm knip`
- Manual: started the OSS dev server, generated a single-output asset
via the queue API, opened the assets sidebar, right-clicked the asset,
and confirmed the Download menu item triggers a single-file download
(screenshot attached).

## Screenshots

![Asset context menu open showing the Download item alongside Inspect,
Insert, Open/Export workflow, Copy job ID, and
Delete](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4727a22df87c291f5308dc348f591650709465b85acabe6b32a3982700450920/pr-images/1777331763941-b7877d53-7271-4a47-a18a-266842e193b6.png)

![Assets sidebar after clicking Download on a single-output asset;
context menu dismissed, no
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4727a22df87c291f5308dc348f591650709465b85acabe6b32a3982700450920/pr-images/1777331764293-4849e094-a8d2-4553-8cf7-2d050f3cc072.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11700-fix-route-context-menu-Download-through-downloadMultipleAssets-34f6d73d365081eb8135e8b699640d97)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 06:31:45 +00:00
Alexander Brown
9d61b4df06 feat: ECS Phase 0b — ID type aliases (FE-166/475/476/477) (#11699)
## Summary

ECS Phase 0b — type-only ID aliases. Builds on FE-165 (centralized
version counter, base of this PR) and adds a tranche of named ID aliases
plus mechanical adoption at known call sites.

This PR now covers four child tickets:

- **FE-166** — adds `GroupId` (in `LGraphGroup.ts`) and `SlotIndex` (in
`interfaces.ts`); re-exported from the litegraph barrel.
- **FE-475** — mechanical adoption of existing aliases (`NodeId`,
`LinkId`, `RerouteId`, `GroupId`, `SlotIndex`, `ExecutionId`) across
litegraph at the audit-listed sites:
`LGraphState.lastGroupId/lastLinkId/lastRerouteId`,
`LGraphExtra.linkExtensions`, `ISerialisedGroup.id`,
`ISerialisedGraph.last_link_id`, `LinkNetwork.removeReroute`,
`INodeOutputSlot.slot_index`, `LGraphNode.{setOutputDataType,
getInputDataType, getOutputPos}` slot params,
`ExecutableNodeDTO.inputs[].linkId` + execution-id locals, and
`RenderLink/MovingLinkBase.fromSlotIndex` (plus subclasses that
redeclare).
- **FE-476** — adds `SubgraphId = UUID` in `LGraph.ts`; adopted at
`_subgraphs` Map, `findUsedSubgraphIds`, `getDirectSubgraphIds`, and
`ExportedSubgraphInstance.type`. Re-exported from the litegraph barrel.
- **FE-477** — adds app-domain entity aliases at their closest
schema/types files: `WorkflowId`, `AssetId`, `PromptId` (propagated as
existing `JobId`), `TaskId`, `UserId`, `WorkspaceId`,
`WorkspaceInviteId`, `NodePackId`. Adopted at primary use sites (entity
id fields, store state, service signatures).

## Entity reference

### ID aliases at a glance

| Alias | Underlying | Defined in | Identifies |
| --- | --- | --- | --- |
| `NodeId` | `number \| string` | `litegraph/LGraphNode.ts` | A node
within a graph |
| `LinkId` | `number` | `litegraph/LLink.ts` | A connection between two
slots |
| `RerouteId` | `number` | `litegraph/Reroute.ts` | A reroute waypoint
on a link |
| `GroupId` *(new)* | `number` | `litegraph/LGraphGroup.ts` | A visual
group of nodes |
| `SlotIndex` *(new)* | `number` | `litegraph/interfaces.ts` | A slot's
position on a node |
| `ExecutionId` | `string` | `litegraph/types/serialisation.ts` | A node
within a subgraph instance |
| `SubgraphId` *(new)* | `UUID` | `litegraph/LGraph.ts` | A subgraph
definition |
| `WorkflowId` *(new)* | `string` |
`platform/workflow/validation/schemas/workflowSchema.ts` | A saved
workflow document |
| `AssetId` *(new)* | `string` |
`platform/assets/schemas/assetSchema.ts` | A binary asset (model, image,
etc.) |
| `JobId` *(reused as `PromptId`)* | `string` | `schemas/apiSchema.ts` |
A queued prompt execution |
| `TaskId` *(new)* | `string` | `platform/tasks/services/taskService.ts`
| A backend background task |
| `UserId` *(new)* | `string` | `types/authTypes.ts` | An authenticated
user |
| `WorkspaceId` *(new)* | `string` |
`platform/workspace/workspaceTypes.ts` | A workspace |
| `WorkspaceInviteId` *(new)* | `string` |
`platform/workspace/workspaceTypes.ts` | A pending workspace invite |
| `NodePackId` *(new)* | `string` |
`workbench/extensions/manager/types/comfyManagerTypes.ts` | A Comfy
Registry / Manager node pack |

### How the entities relate

```mermaid
flowchart TB
  subgraph LG["🎨 Litegraph entities"]
    direction TB
    Graph["LGraph<br/>id: SubgraphId"]
    Subgraph["Subgraph<br/>id: SubgraphId"]
    Node["LGraphNode<br/>id: NodeId"]
    Link["LLink<br/>id: LinkId"]
    Reroute["Reroute<br/>id: RerouteId"]
    Group["LGraphGroup<br/>id: GroupId"]
    Slot["INodeSlot<br/>slot_index: SlotIndex"]
    Exec["ExecutableNodeDTO<br/>id: ExecutionId"]
  end

  subgraph AP["🌐 App-domain entities"]
    direction TB
    Workflow["ComfyWorkflow<br/>id: WorkflowId"]
    Job["Prompt / Job<br/>id: JobId ≡ PromptId"]
    Task["Task<br/>id: TaskId"]
    Asset["Asset<br/>id: AssetId"]
    Workspace["Workspace<br/>id: WorkspaceId"]
    Invite["WorkspaceInvite<br/>id: WorkspaceInviteId"]
    User["User<br/>id: UserId"]
    Pack["NodePack<br/>id: NodePackId"]
  end

  Subgraph -. extends .-> Graph
  Graph --> Node
  Graph --> Link
  Graph --> Group
  Node --> Slot
  Link --> Reroute
  Link --> Slot
  Node -. instantiates .-> Subgraph
  Exec -. wraps .-> Node

  Workflow -. serializes .-> Graph
  Job --> Workflow
  Job --> Task
  Task --> Asset
  Workspace --> Workflow
  User --> Workspace
  Workspace --> Invite
  Pack -. provides .-> Node
```

Solid arrows are containment / direct references; dashed arrows are *“is
a kind of”* (`extends`) or cross-layer relationships (e.g. a
`ComfyWorkflow` *serializes* an `LGraph`; a `NodePack` *provides* node
definitions).

## Explicit non-goals

- `LGraphState.lastNodeId` is intentionally kept as bare `number`
(auto-increment counter; would widen if aliased to `NodeId = number |
string`).
- No new `SubgraphSlotId` alias — verified subsidiary (subgraph IO slots
are addressed via `SUBGRAPH_INPUT_ID/OUTPUT_ID` sentinel + numeric array
index, not by UUID alone).
- No `WidgetName`, `SlotName`, `WorkspaceMemberId` — verified subsidiary
(only meaningful inside a parent or as a relationship).
- No re-typing of `LGraph.id` / `Subgraph.id` — references adopt
`SubgraphId`, but the inherited UUID typing is left intact (minimal
diff).

## Type-only

All changes are structural-equivalent type aliases. No runtime behavior
changes. No new exports beyond the aliases themselves. No generated code
modified.

## Verification

- `pnpm typecheck` 
- `pnpm knip` 
- Scoped `npx eslint` on changed files 
- Lint-staged hooks (oxfmt, oxlint, eslint, typecheck) passed on every
commit

## Notes for reviewers

This branch was rebased onto `main` after FE-165 (`a441364a5`, PR
#11698) merged independently — the auto-skipped FE-165 commit is no
longer part of this PR. Six commits remain (oldest → newest):

| Commit | Maps to | Summary |
| --- | --- | --- |
| `e8e7ff795` | FE-166 | Add `GroupId` and `SlotIndex` aliases + barrel
re-exports |
| `e0bcb75a0` | FE-476 | Add `SubgraphId = UUID` alias |
| `2c136afb9` | FE-477 + FE-475 bulk | Add app-domain aliases;
mechanical adoption of
`NodeId`/`LinkId`/`RerouteId`/`GroupId`/`SlotIndex`/`ExecutionId`/`SubgraphId`
at audit-listed litegraph sites |
| `06d6e6a8b` | FE-477 | Adopt `TaskId` in asset stores |
| `f943e1c2b` | FE-476 | Adopt `SubgraphId` at remaining UUID reference
sites (`LGraphCanvas` clipboard map + paste, `SubgraphNode.type`) |
| `1739d5241` | review feedback | Tighten alias usage:
`linkExtensions.parentId: RerouteId`, drop redundant `String()` wraps in
`executionStore`, type `assetExportStore` map as `Map<TaskId,
AssetExport>` |

FE-475's mechanical adoption is bundled into `2c136afb9` rather than a
dedicated commit (parallel-agent execution on a shared working tree);
the substitutions themselves are complete — see the diff under
`src/lib/litegraph/src/`. PR will be squash-merged, so commit
granularity is informational.

Fixes FE-166
Fixes FE-475
Fixes FE-476
Fixes FE-477

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 21:36:06 -07:00
Christian Byrne
963a7bf178 refactor: consolidate browser_tests/helpers/ into fixtures/ (#11411)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Eliminates the confusing dual-helpers structure where
`browser_tests/helpers/` and `browser_tests/fixtures/helpers/` coexisted
one tier apart with overlapping purposes
- Routes each file to its natural home based on what it actually *is*:
page objects → `components/`, standalone utils → `utils/`, domain helper
classes stay in `helpers/`
- Adds an ESLint guard (`no-restricted-imports`) to prevent re-creating
`browser_tests/helpers/`

## File Moves

| File | From | To | Reason |
|---|---|---|---|
| `actionbar.ts` | `helpers/` | `fixtures/components/Actionbar.ts` |
Page object class imported by ComfyPage |
| `templates.ts` | `helpers/` | `fixtures/components/Templates.ts` |
Page object class imported by ComfyPage |
| `boundsUtils.ts` | `fixtures/helpers/` | `fixtures/utils/` | Pure
function, not a helper class |
| `mimeTypeUtil.ts` | `fixtures/helpers/` | `fixtures/utils/` | Pure
function, not a helper class |
| `builderTestUtils.ts` | `helpers/` | `fixtures/utils/` | Shared test
setup functions |
| `clipboardSpy.ts` | `helpers/` | `fixtures/utils/` | Page injection
utility |
| `fitToView.ts` | `helpers/` | `fixtures/utils/` | Canvas utility
function |
| `manageGroupNode.ts` | `helpers/` | `fixtures/utils/` | Litegraph
interaction helper |
| `painter.ts` | `helpers/` | `fixtures/utils/` | Test helper functions
|
| `perfReporter.ts` | `helpers/` | `fixtures/utils/` | Test
infrastructure |
| `promotedWidgets.ts` | `helpers/` | `fixtures/utils/` | Query helpers
for specs |

## What Changed Beyond File Moves

- **28 import statements** updated across test specs, fixtures, and
infra files
- **AGENTS.md** — directory tree diagram and architectural separation
descriptions updated
- **README.md** — "Leverage Existing Fixtures and Helpers" section
updated
- **`.claude/skills/perf-fix-with-proof/SKILL.md`** — perfReporter path
reference updated
- **`eslint.config.ts`** — added `@e2e/helpers/*` restricted import
pattern to both spec and non-spec browser_tests rules

## Verification

- `pnpm typecheck` — clean
- `pnpm typecheck:browser` — clean
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm format:check` — all files formatted
- `pnpm knip` — clean
- Pre-commit hooks passed full pipeline (oxfmt, oxlint, eslint,
typecheck, typecheck:browser)

## Config Audit

No changes needed to: `tsconfig.json` (`@e2e/*` alias covers all
subdirs), `playwright.config.ts`, `vite.config.mts`, `knip.config.ts`,
`.oxlintrc.json`, `nx.json`

## Manual Verification Note

This is a pure structural refactoring (file moves + import updates) with
zero behavioral or visual changes. The typecheck and lint passes confirm
all imports resolve correctly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11411-refactor-consolidate-browser_tests-helpers-into-fixtures-3476d73d3650816cb671ef7fa8433f66)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 02:18:31 +00:00
Terry Jia
f001bc9af3 feat: derive Preview3D camera pose from EXTRINSICS/INTRINSICS matrices (#11626)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
splat workflows (and PLY pipelines that go through SHARP / COLMAP) emit
camera pose as extrinsics + intrinsics matrices, and the viewer needs a
way to consume them before that end-to-end story can ship.

## Summary

Adds the ability to drive the Preview3D camera from a pair of
OpenCV-convention extrinsics + intrinsics matrices (as produced by SHARP
/ COLMAP / other SfM pipelines), so backend nodes that emit such
matrices can position the viewer's camera deterministically. Fifth in
the series splitting up
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495. Fully
self-contained — adds new API surface only, no existing call paths are
modified.

## Changes

- **What**:
- `cameraFromMatrices.ts` (new): pure function
`computeCameraFromMatrices(extrinsics, intrinsics)` returning `{
position, target, fovYDegrees }`. No THREE.js dependency.
Shape-validates the inputs (4×4 and 3×3) with a clear error.
- `Load3d.setCameraFromMatrices(extrinsics, intrinsics)`: wires the
utility into Load3d via `setCameraState` + `setFOV`. Preserves the
caller's existing `zoom` and `cameraType`.
- `load3d.ts` (extension): Preview3D's `onExecuted` extracts
`extrinsics`/`intrinsics` from `result[3]`/`result[4]` (when present)
and calls `setCameraFromMatrices`. The output tuple type is widened to
include the two optional `Matrix` fields.

## Review Focus

- **Convention conversion**: OpenCV is Y-down, Z-forward; three.js is
Y-up, Z-back. The function flips Y and Z on both `position` and `target`
(equivalent to a 180° X-axis rotation of the whole world). The same
rotation is applied elsewhere to splats at load time, so a future splat
+ camera-pose pair lines up.
- **FOV math**: vertical FOV (radians) = `2 * atan(cy / fy)`. We expose
it in degrees, matching `cameraManager.setFOV`'s contract.
- **Camera-state preservation**: `setCameraFromMatrices` reads the
current state to keep `zoom` and `cameraType` intact — only `position`,
`target`, and `fov` come from the matrices.
- **Backwards compatibility**: backend nodes that don't return matrices
simply skip the new branch (the `if (extrinsics && intrinsics)` guard).
Existing Preview3D workflows are unaffected.
- 7 unit tests for the matrix math (identity, rotation, translation, FOV
derivation, shape errors, etc.); 1 unit test for the Load3d wiring
(verifies the destructured `position`/`target`/`fovYDegrees` reach
`setCameraState` + `setFOV` with `zoom`/`cameraType` preserved).

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `cameraFromMatrices.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | ~7.6% | 0% | ~14.6% | ~7.7% |
| `load3d.ts` (extension, modified) | 0% | 0% | 0% | 0% |

The `Load3d.ts` numbers are the pre-existing baseline — `Load3d.test.ts`
covers façade methods via prototype injection rather than instantiating
the class (the constructor needs `THREE.WebGLRenderer`, which happy-dom
can't provide). The new `setCameraFromMatrices` method body is exercised
by a new unit test that asserts `setCameraState` receives the
destructured matrix output and `setFOV` receives the computed
`fovYDegrees`, with the caller's `zoom`/`cameraType` preserved.

`load3d.ts` (the extension registration file, 0% on `main` and after
this PR) has no unit-test scaffolding in the project — its `onExecuted`
handler runs only after a workflow execution and is exercised end-to-end
via browser tests. The new `if (extrinsics && intrinsics)
load3d.setCameraFromMatrices(...)` branch sits in that path.

Net: the matrix math itself, which previously didn't exist, is now 100%
covered. The wiring layer relies on the same e2e safety net the
surrounding code has always used.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11626-feat-derive-Preview3D-camera-pose-from-EXTRINSICS-INTRINSICS-matrices-34d6d73d3650817297bdf25a83a4f7a2)
by [Unito](https://www.unito.io)
2026-04-27 21:58:12 -04:00
Kelly Yang
88faaf3d86 test: complete remaining Painter widget E2E tests (#11613)
## Summary

Implemented E2E test coverage for Levels 6-12 of the Painter Widget

## Changes

Adds the following coverage to complete the test plan:

Level 6 - Input image connection (3 tests, @slow):
  - Width/height/bg-color controls hide when input is linked
  - Canvas resizes to match input image dimensions after execution
  - Drawing over input image produces canvas content

Level 7 - Clear on empty canvas is harmless

Level 8 - Unchanged canvas does not re-upload on second serialization

Level 9 - Settings persistence:
  - Tool selection saved to node.properties.painterTool
  - Brush size change saved to node.properties.painterBrushSize

Level 10 - Compact layout collapses to grid-cols-1 when node width <
350px

Level 12 - Rapid drawing accumulates all strokes (checks 3 y-positions)

Supporting changes:
- Add data-testid="painter-controls" to controls grid in
WidgetPainter.vue (needed for compact mode class assertion)
- Add browser_tests/assets/widgets/painter_with_input.json workflow
fixture (LoadImage connected to Painter input slot 0)



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly adds/adjusts Playwright and unit test coverage; the only
runtime change is wrapping pointer-capture calls in `try/catch`, which
is low-risk but touches input-handling paths.
> 
> **Overview**
> Completes and expands Painter widget browser test coverage, including
new scenarios for clearing an empty canvas, preventing redundant uploads
when serializing an unchanged canvas, persisting tool/brush-size
settings to node properties, compact layout behavior, multi-stroke
accumulation checks, and an input-image-connected workflow (new
`painter_with_input.json`) with execution/resizing/draw-over-image
assertions.
> 
> Hardens `usePainter` pointer handling by tolerating
`setPointerCapture`/`releasePointerCapture` failures (e.g., synthetic
events), with corresponding unit tests updated/added to validate the
behavior and serialization expectations.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
056d4a9f0c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11613-test-complete-remaining-Painter-widget-E2E-tests-34c6d73d36508158b620f55aa1981cf5)
by [Unito](https://www.unito.io)
2026-04-27 21:41:45 -04:00
Terry Jia
42ff7b6c62 refactor(load3d): drive viewer behavior from ModelAdapter capabilities (#11660)
> Final architectural step in the PLY / 3D Gaussian Splatting series.
Previous PR introduced `ModelAdapter` with a dormant `capabilities`
field; this PR makes those capabilities load-bearing and replaces the
remaining `instanceof SplatMesh` / `instanceof BufferGeometry` switches
with adapter-driven dispatch. Together with previous one it removes the
last of the format-specific branching from `SceneModelManager` /
`Load3d`. Seventh in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Summary

Drive viewer behavior (fit-to-viewer, default camera pose, world bounds,
GPU dispose, material rebuild) from `ModelAdapterCapabilities` + 3 new
optional adapter methods, instead of `SceneModelManager` reflecting on
model shape. `Load3d` is rewritten to take its 13 managers as injected
`Load3dDeps`; a new `createLoad3d` factory assembles them and threads a
single `AdapterRef` between `LoaderManager` (writer) and
`SceneModelManager` + `Load3d` (readers). Splat orientation +
decoder-race + sizing bugs are fixed as a side effect — splats now
render upright, fill the grid, and don't lock the OrbitControls target
on first frame.

## Changes

- **`ModelAdapter.ts`**: add `AdapterRef = { current: ModelAdapter |
null }` shared handle and 3 optional adapter methods —
`computeBounds(model)`, `disposeModel(model)`, `defaultCameraPose()`.
- **`SplatModelAdapter.ts`**: implement all 3 optional methods; `await
splatMesh.initialized` so first-frame bounds are populated (fixes a
decoder race that collapsed the OrbitControls target onto the camera);
`quaternion.set(1, 0, 0, 0)` to convert sparkjs's OpenCV (Y-down,
Z-forward) to three.js (Y-up, Z-back); flip `fitToViewer` back to `true`
and bump `fitTargetSize` to `20` so splats fill the 20-unit grid instead
of shrinking to 1/4 of it.
- **`PointCloudModelAdapter.ts`**: extract
`buildPointCloudForMaterialMode` so `SceneModelManager` rebuilds via the
same code path the initial load uses; `setPath` so PLYs that reference
relative assets resolve correctly.
- **`LoaderManager.ts`**: accept optional `AdapterRef`; write through it
instead of the internal `_currentAdapter` field. `clearModel()` now runs
while the old adapter is still current so its `disposeModel()` can
release renderer-owned resources.
- **`SceneModelManager.ts`**: accept 4 capability lambdas
(`getCurrentCapabilities` / `getBoundsFromAdapter` /
`disposeModelViaAdapter` / `getDefaultCameraPose`) with
`DEFAULT_MODEL_CAPABILITIES` / null fallbacks. `setupModel`,
`fitToViewer`, and material-mode rebuild are now capability-driven;
`containsSplatMesh` (30 lines of `instanceof` traversal) and
`handlePLYModeSwitch` (90 lines of duplicated PLY rebuild) are gone.
- **`Load3d.ts`**: ctor switches from manager-creation to deps-injected
(`Load3dDeps`); add `getCurrentModelCapabilities()` reader; gate
`setGizmoEnabled` / `setGizmoMode` / `resetGizmoTransform` /
`applyGizmoTransform` on `capabilities.gizmoTransform`; `isSplatModel` /
`isPlyModel` now read `adapterRef.current?.kind` directly.
- **`createLoad3d.ts`** (new): single factory that builds the renderer
(`createRenderer`), assembles all 13 managers in dependency order, and
threads one shared `AdapterRef` through `LoaderManager` and
`SceneModelManager`'s 4 capability lambdas.
- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: switch from `new
Load3d(container, options)` to `createLoad3d(container, options)`. No
other call-site changes.

## Review Focus

- **Capability dispatch parity**: walk each former hardcoded branch in
`SceneModelManager` and confirm it now falls out of the right
capability:
- `containsSplatMesh()` → `!capabilities.fitToViewer` +
`getDefaultCameraPose()`
- `handlePLYModeSwitch()` → `capabilities.requiresMaterialRebuild` +
`buildPointCloudForMaterialMode()`
- `Box3.setFromObject(model)` for sizing → `getBoundsFromAdapter(model)
?? Box3.setFromObject(model)`
- Mesh/Points geometry+material disposal in `clearModel` → still
happens, plus `disposeModelViaAdapter(obj)` for adapter-owned resources
(sparkjs SplatMesh internal GPU state)
- **`AdapterRef` lifecycle**: one ref is created in `createLoad3d`,
passed to `LoaderManager` (writer) and `SceneModelManager` (read via 4
closures). `LoaderManager.loadModel` clears via the *old* adapter first
(so `disposeModel` runs), then null-resets the ref before picking the
new one. Test `keeps the old adapter current while clearModel runs` pins
this ordering.
- **Splat fixes are user-visible, not pure refactor**:
- Orientation: `quaternion.set(1, 0, 0, 0)` matches the sparkjs README
convention. Without it splats render upside-down and mirrored on Z. Same
rotation is applied to the camera-from-matrices output in PR-E so a
future splat + camera-pose pair lines up.
- Decoder race: `await splatMesh.initialized` ensures `getBoundingBox`
returns a non-zero box on the first call. Without it `setupModel`'s
bounds → camera pipeline placed the OrbitControls target on the camera
origin, locking the view.
- Sizing: `fitTargetSize: 20` (vs. the mesh default of 5) means splat
geometry spans the full 20-unit grid footprint instead of ~1/4 of it.
Mesh assets are unaffected.
- **Gizmo gating**: `setGizmoEnabled(true)` early-returns when
`capabilities.gizmoTransform` is false. Internal
`setGizmoEnabled(false)` still runs (so we can always disable).
`setGizmoMode` / `resetGizmoTransform` / `applyGizmoTransform` no-op
when the capability is off.
- **`createLoad3d` is the single ctor entry**: `new Load3d(...)` is no
longer callable from app code (ctor signature changed to `(container,
deps, options)`). All call sites use `createLoad3d`. Test scaffolding
still uses `Object.create(Load3d.prototype)` + property injection where
it needs to bypass renderer creation.
- **Backwards compatibility**: `LoaderManager`'s `adapterRef` and
`SceneModelManager`'s 4 capability lambdas all have defaults
(`createAdapterRef()` and `() => DEFAULT_MODEL_CAPABILITIES` etc.), so
the existing test suites that construct these classes with the old
signatures still compile and pass without modification beyond what's in
this PR.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.7% | 86.7% | **100%** |
| `MeshModelAdapter.ts` (unchanged) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (modified) | **97.9%** | 69.2% | 71.4% |
**97.9%** |
| `SplatModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `SceneModelManager.ts` (modified) | 75.4% | 67.2% | 72.2% | 75.4% |
| `Load3d.ts` (modified) | 29.5% | 30.6% | 26.7% | 30.1% |
| `createLoad3d.ts` (new) | 83.8% | **100%** | 58.3% | 83.8% |
| `useLoad3d.ts` (modified) | 78.2% | 65.1% | 71.4% | 82.2% |
| `useLoad3dViewer.ts` (modified) | 75.2% | 52.1% | 65.9% | 79.4% |

`SplatModelAdapter.ts` jumps to 100% via 6 new tests covering the
orientation set, the `await initialized` decoder wait, `computeBounds`
(world-space transform + null fallback), `disposeModel` (per-SplatMesh
dispose + no-op on non-splat trees), and `defaultCameraPose`.

`createLoad3d.ts` hits 100% branch via a new test file with 12 cases —
`WebGLRenderer` config, `Load3DOptions` forwarding, `AdapterRef`
identity between `LoaderManager` and `SceneModelManager`, and the 4
capability lambdas in both adapter-null and adapter-published states
(each delegates correctly to the adapter's optional methods or falls
back to defaults). The remaining func% reflects the inline
`gizmoTransformChange` callback — not a deliberate skip, just out of
scope for the dispatch-wiring tests.

`SceneModelManager.ts` and `Load3d.ts` numbers are the pre-existing
baseline — the existing `*.test.ts` files cover façade methods via
prototype injection rather than instantiating the classes (`Load3d`
constructor needs `THREE.WebGLRenderer`, which happy-dom can't provide;
`SceneModelManager` covers the new capability paths via its existing
`createManager(overrides)` helper). All new branches (capability gating,
capability-driven `setupModel` / `fitToViewer` / rebuild, adapter-driven
`isSplatModel` / `isPlyModel`) have dedicated tests.

Net diff: **+846 / −370** across 16 files (10 production, 6 test).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11660-refactor-load3d-drive-viewer-behavior-from-ModelAdapter-capabilities-34f6d73d36508130b0ece884add182b9)
by [Unito](https://www.unito.io)
2026-04-27 21:32:21 -04:00
Dante
f404887c96 test: add unit tests for linkFixer (#11668)
## Summary

Adds unit coverage for `linkFixer` serialized graph repair paths and
fixes input-slot repairs being tracked without mutating the serialized
workflow data.

## Changes

- **What**: Adds 10 Vitest cases for valid links, dry-run reporting,
origin/target repair, missing input-slot cleanup, dangling node cleanup,
stale link deletion, and silent mode.
- **What**: Applies serialized input slot mutations in fix mode and
reports `fixed` only after a rerun confirms the graph is clean.
- **What**: Adds the design-system package to knip workspace config so
the pre-push hook recognizes its exported CSS dependency usage.
- **Dependencies**: None.

## Review Focus

The workflow validation path calls `fixBadLinks` with serialized
workflow JSON, so the new tests stay at that boundary and assert the
repaired graph shape directly.

## Test Coverage

- CI snapshot for `src/utils/linkFixer.ts`: unit lines 1.8%.
- Local targeted coverage: unit lines 83.9%, functions 94.11%, branches
75.15%.

## Testing

```bash
pnpm format -- src/utils/linkFixer.ts src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts --coverage
pnpm typecheck
pnpm lint
pnpm knip
```


## screenshoot
<img width="1691" height="927" alt="Screenshot 2026-04-28 at 10 09
26 AM"
src="https://github.com/user-attachments/assets/ff59888f-bde3-48f5-853a-60df69e84492"
/>
2026-04-28 01:11:25 +00:00
Comfy Org PR Bot
b37c66539b 1.44.12 (#11705)
Patch version increment to 1.44.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11705-1-44-12-3506d73d36508121bc39e748c87a6235)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 00:38:05 +00:00
Alexander Brown
95e9a9405b test(subgraph): pin #10849 promoted-widget-value corruption with it.fails (#11697)
*PR Created by the Glary-Bot Agent*

---

## Summary

#11579 restored *categorical* test coverage for subgraph serialization
but didn't reproduce the specific Z-Image-Turbo regression introduced by
#10849 — pre-#10849 templates whose `widgets_values` is leftover noise
get corrupted on load because the new code applies that array
positionally to promoted widget views.

This PR adds two **vitest** cases that pin the user-visible symptom
directly: after loading a misaligned legacy payload, the promoted widget
value should reflect the source default, not the legacy
`widgets_values[i]`.

Both use `it.fails` so the suite stays green while the bug is present
and flips to failing the moment the fix on
`fix/subgraph-promoted-widget-inline-state` lands.

## Tests

1. `falls back to source widget value when proxyWidgets is in legacy
2-tuple shape` — configure() with `proxyWidgets: [['-1', 'widget']]` +
`widgets_values: [999]` should leave the widget at the source default
(42), not 999.
2. `does not corrupt unbound promoted widgets when widgets_values length
mismatches view count` — same shape with longer/wrong-length array.

## Verification

- All 8 cases in the file pass under `it.fails` (CI green).
- Removing `.fails` locally produces the expected failures: `expected
42, received 999` and `expected 42, received 111` — confirming both
tests catch the regression.
- `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxlint` all clean.

## Why `it.fails` and not plain failing tests

The actual fix (`fix/subgraph-promoted-widget-inline-state`) is
unmerged. Landing genuinely-failing tests on main would break CI for
everyone. `it.fails` documents the bug runnably, keeps CI green, and
signals when the fix lands so the marker can be dropped.

## Why these assertions, not "widgets_values must be undefined"

A first draft asserted that `serialize()` should not write
`widgets_values` at all. That conflicts with existing coverage in the
same file (`preserves per-instance widget values after configure`,
`round-trips per-instance widget values`) which deliberately uses
`widgets_values` for round-trip persistence. These rewritten assertions
target the load-time corruption symptom directly without contradicting
the per-instance contract — feedback from Oracle review.

## Coordination

Mirrors the failing tests already on commit `6a982675e` of the unmerged
`fix/subgraph-promoted-widget-inline-state` branch, with the addition of
`.fails` markers and a clarifying comment so they can land on main
first.

Follow-up to #11579.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11697-test-subgraph-pin-10849-promoted-widget-value-corruption-with-it-fails-34f6d73d365081d7a04dcf48ebeceafe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 00:13:48 +00:00
Dante
00974d6339 test: add E2E tests for publish flow wizard (#10770)
## Summary
- Add Playwright E2E test coverage for the ComfyHub publish workflow
dialog
- Create `PublishDialog` page object fixture with programmatic dialog
opening via Vite dynamic imports
- Create `PublishApiHelper` for mocking all publish flow API endpoints
(`/hub/profiles/me`, `/hub/labels`, `/hub/workflows`,
`/userdata/*/publish`, `/assets/from-workflow`,
`/hub/assets/upload-url`)
- Add `data-testid` attributes to 6 publish flow components for stable
E2E locators
- 17 test scenarios across 7 describe blocks covering wizard navigation,
form interactions, profile gate, save prompt, and publish submission

## Test plan
- [ ] Run `pnpm test:browser:local -- --grep "Publish dialog"` against
local dev server
- [ ] Verify wizard navigation through Describe → Examples → Finish
steps
- [ ] Verify profile gate flow (with/without profile)
- [ ] Verify save prompt for unsaved workflows
- [ ] Verify publish success/failure scenarios

Fixes #9079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10770-test-add-E2E-tests-for-publish-flow-wizard-3346d73d3650818094d5fc3a84593402)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante <dante@danteui-MacStudio.local>
Co-authored-by: GitHub Action <action@github.com>
2026-04-28 00:13:43 +00:00
Dante
878ffb70cc test: add unit tests for assetService (#11670)
## Summary

Extends `assetService.test.ts` (which previously only covered
`shouldUseAssetBrowser`) with behavioral tests for the network-bound
methods: metadata fetch error mapping, base64 upload validation,
async-vs-sync upload routing, delete error propagation, model-folder
filtering, update validation, and tag-filtered list defaults.

## Changes

- **What**: Adds 10 Vitest cases across `getAssetMetadata`,
`uploadAssetFromBase64`, `uploadAssetAsync`, `deleteAsset`,
`getAssetModelFolders`, `updateAsset`, and `getAssetsByTag`. Reuses the
existing hoisted `mockDistributionState` / `mockSettingStoreGet` setup
and the existing `vi.mock('@/scripts/api')` boundary; adds local
`buildResponse` and `validAsset` helpers scoped to this file.

## Review Focus

- The localized error path is covered through public methods
(`getAssetMetadata`) rather than reaching for the internal
`getLocalizedErrorMessage`.
- `getAssetModelFolders` test asserts that the request URL omits
`include_public` (the internal call site passes no `includePublic`),
matching the conditional in `handleAssetRequest`.
- `uploadAssetAsync` tests pin the discriminated-union shape (`type:
'async' | 'sync'`) for both 202 and 200 responses.

## Testing

\`\`\`bash
pnpm exec vitest run src/platform/assets/services/assetService.test.ts
pnpm format -- src/platform/assets/services/assetService.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

All 16 tests pass (6 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11670-test-add-unit-tests-for-assetService-34f6d73d36508117b1aafaf463e9c820)
by [Unito](https://www.unito.io)
2026-04-28 08:45:01 +09:00
Alexander Brown
a441364a55 refactor(litegraph): centralize _version counter via incrementVersion() (#11698)
## Summary

Centralize all `LGraph._version` increments behind a single
`incrementVersion()` method to create the seam for a future
`VersionSystem` (ECS Migration Phase 0a).

## Changes

- **What**: Added `LGraph.incrementVersion()` and replaced all 19 direct
`graph._version++` writes across 7 files. Existing null guards at call
sites are preserved. Zero behavioral change — the counter is still only
read by `LGraphCanvas.renderInfo()` for debug display.

## Review Focus

- The new method is mechanical: `incrementVersion(): void {
this._version++ }`. Look for any sites I missed or null-guard
regressions.
- Files updated: `LGraph.ts`, `LGraphNode.ts`, `LGraphCanvas.ts`,
`widgets/BaseWidget.ts`, `subgraph/SubgraphInput.ts`,
`subgraph/SubgraphInputNode.ts`, `subgraph/SubgraphOutput.ts`.

Part of the [ECS Migration
Plan](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md).
Linear: FE-165.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11698-refactor-litegraph-centralize-_version-counter-via-incrementVersion-34f6d73d3650810992f8fa3adbae3f38)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 23:25:41 +00:00
Alexander Brown
8dcadd6fe1 test: restore deleted subgraph serialization E2E tests (#11579)
*PR Created by the Glary-Bot Agent*

---

## Summary

#10759 removed ~12 E2E tests from `subgraphSerialization.spec.ts` during
a reorganization that shifted semantic coverage to Vitest. Several of
the removed tests covered the exact `serialize() → JSON → configure()`
round-trip that #10849's positional `_instanceWidgetValues` path later
regressed on Main — promoted widget values binding to the wrong slots
when loading templates whose `widgets_values` ordering doesn't match
current `proxyWidgets`.

This restores the pre-reorg E2E coverage so future regressions in
promoted-widget serialization are caught at the browser level.

## Restored tests

From `subgraphSerialization.spec.ts` (pre-#10759):

- **Deterministic proxyWidgets Hydrate** (3 tests) — round-trip
stability and compressed `target_slot` resolution.
- **Legacy And Round-Trip Coverage** (5 tests) — includes the most
directly-relevant restorations:
  - `Promoted widgets survive serialize -> loadGraphData round-trip`
  - `Multi-link input representative stays stable through save/reload`
- `Cloning a subgraph node keeps promoted widget entries on original and
clone`
- **Duplicate ID Remapping** (5 tests) — includes `Promoted widget
tuples are stable after full page reload boot path`.

The 4 tests that already existed on Main (added by #10849 and the
Vue-nodes legacy-prefixed block) are kept as-is.

## Adaptations to current APIs

- Imports reworked for the post-`@e2e/*` alias layout.
- Redundant `comfyPage.nextFrame()` calls dropped — `loadWorkflow` /
`loadGraphData` / `serializeAndReload` already wait internally (#11264).
- Alt-drag clone block wrapped in `try/finally` around
`keyboard.up('Alt')` to match the current `subgraphCrud.spec.ts`
pattern.
- `PromotedWidgetEntry` is now exported from
`browser_tests/helpers/promotedWidgets.ts` so the restored
`expectPromotedWidgetsToResolveToInteriorNodes` helper can type its
argument.

## Review follow-ups applied

- Use `expect.poll()` instead of `expect(async () => …).toPass()` for
the single-value snapshot comparison, per `browser_tests/AGENTS.md`.
- Capture and call `dispose()` from
`SubgraphHelper.collectConsoleWarnings()` inside a `try/finally` so the
console listener is unregistered after the test.

## Verification

- `pnpm typecheck:browser` — clean.
- `pnpm exec eslint` + `pnpm exec oxlint` on changed files — 0 warnings,
0 errors.
- `pnpm exec oxfmt` on changed files — applied (no diff).
- Ran 2 key restored tests against local ComfyUI + dev server:
- `Promoted widgets survive serialize -> loadGraphData round-trip` —
PASS (3.9s)
- `Multi-link input representative stays stable through save/reload` —
PASS (2.9s)
- Full-suite runs in this sandbox are blocked by the existing
`comfyPage` fixture's `createUser` path failing on repeat runs against
persistent backend state — unrelated to this PR. CI will exercise the
full suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11579-test-restore-deleted-subgraph-serialization-E2E-tests-34b6d73d365081f29b27c1069476ad17)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 21:31:59 +00:00
Simon Pinfold
3a05a37323 Fix naming strategy for multi-job asset exports (#11610)
## Summary

Use `group_by_job_time` for exports spanning multiple jobs while keeping
single-job exports on `preserve`, and add regression coverage for the
new naming-strategy behavior.

## Changes

- **What**: updated the asset export payload and request typing for the
new naming-strategy values, added unit coverage for single-job vs
multi-job export requests, added `@cloud` sidebar browser coverage for
export payloads, and adjusted the cloud Playwright setup helpers so
setup API calls can hit the backend directly and Firebase auth is seeded
on the app origin
- **Breaking**: none
- **Dependencies**: none

## Review Focus

Please sanity-check the cloud Playwright harness changes in `ComfyPage`
and `CloudAuthHelper`, plus the single-job vs multi-job export
naming-strategy assertions in the new browser tests.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11610-Fix-naming-strategy-for-multi-job-asset-exports-34c6d73d365081a68a88ea38d897578f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-27 20:44:32 +00:00
Dante
6cfa5fdb1f test: add unit tests for assetsStore (#11672)
## Summary

Extends `assetsStore.test.ts` with behavioral coverage for the
optimistic-update flows (`updateAssetMetadata`, `updateAssetTags`), the
per-asset deletion-state tracker (`setAssetDeleting` /
`isAssetDeleting`), the input-name resolution (`getInputName` /
`inputAssetsByFilename`), and the cloud-routing branch of
`updateInputs`.

## Changes

- **What**: Adds 8 Vitest cases across 4 new `describe` blocks
(`updateAssetMetadata optimistic cache`, `updateAssetTags diff-based
dispatch`, `setAssetDeleting / isAssetDeleting`, `getInputName`,
`updateInputs cloud routing`). Extends the shared `assetService` mock
with `updateAsset`, `addAssetTags`, and `removeAssetTags` (none of which
were previously stubbed).

## Review Focus

- Cloud-routed tests flip `mockIsCloud.value` inside a `try/finally` so
the existing default (`false`) is restored even if an assertion throws —
same pattern the existing Cloud describe block uses.
- The optimistic-cache rollback test silences the `console.error`
invoked by the catch branch so the test output stays clean.
- The `updateAssetTags` tests pin both the no-op short-circuit and the
add-only path. Remove-only and combined add+remove are already exercised
indirectly through the existing tag-cache invalidation tests.

## Testing

\`\`\`bash
pnpm exec vitest run src/stores/assetsStore.test.ts
pnpm format -- src/stores/assetsStore.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

47 tests pass (39 prior + 8 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11672-test-add-unit-tests-for-assetsStore-34f6d73d3650819f8e43e7154541baef)
by [Unito](https://www.unito.io)
2026-04-27 20:40:27 +00:00
Benjamin Lu
3ea75e1c48 fix: localize secret date labels (#11524)
## Summary

Localizes secret list date labels through Vue I18n date formatting
instead of the browser default numeric date format, with Storybook
coverage for design review.

## Changes

- **What**: Replaces `toLocaleDateString()` with `useI18n().d(..., {
dateStyle: 'medium' })` for `SecretListItem` dates.
- **What**: Updates `SecretListItem` expectations to match the Vue I18n
date formatter.
- **What**: Adds `SecretListItem` stories for default, never-used,
loading, and disabled states.
- **Dependencies**: None.

## Review Focus

Stacked on #11480 to keep the escaping fix scoped. Please confirm
whether the localized medium date style matches design/product
expectations.

## Screenshots (if applicable)


https://f3ba7229.comfy-storybook.pages.dev/?path=/story/platform-secrets-secretlistitem--default

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11524-fix-localize-secret-date-labels-3496d73d3650814bb70bfb3f870a31cd)
by [Unito](https://www.unito.io)
2026-04-27 13:11:31 -07:00
Alexander Brown
9c0edd0048 fix: prevent duplicate website-e2e CI runs on PRs (#11607)
## Summary

Fix duplicate `CI: Website E2E` workflow runs on pull requests.

## Problem

Two runs were triggered for every PR touching website files:
- `website-e2e (pull_request)` — from the PR event
- `website-e2e (push)` — from the push to a `website/*` branch

The concurrency key used `github.ref`, which evaluates differently for
push (`refs/heads/...`) vs pull_request (`refs/pull/N/merge`), so they
couldn't cancel each other.

## Changes

1. Scope `push` trigger to `main` only (removes `website/*`)
2. Use `github.head_ref || github.ref` in the concurrency group so push
and PR events for the same branch share a group

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11607-fix-prevent-duplicate-website-e2e-CI-runs-on-PRs-34c6d73d3650814c9d24c77b1591e94a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 19:55:05 +00:00
Benjamin Lu
7ee667c1d1 fix: avoid escaped secret date labels (#11480)
Global i18n config has escapeParameter as true. This explicitly turns it
to false. I opened a Linear ticket to reconsider changing this back to
false as default globally.

## Summary

Fix the Secrets panel so created and last-used dates render as plain
text instead of HTML-escaped slash entities.

## Changes

- **What**: Compute the Secrets date labels with `t(..., {
escapeParameter: false })` after formatting the date, so vue-i18n does
not escape `/` into `&#x2F;` for plain-text output.
- **What**: Replace the mocked translation setup in
`SecretListItem.test.ts` with a real `vue-i18n` instance and add a
regression test that asserts the rendered dates do not contain escaped
slash entities.

## Review Focus

This intentionally fixes the i18n interpolation issue shown in the bug
screenshot. It does not change the separate RFC3339Nano parsing behavior
discussed in #11358.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11480-fix-avoid-escaped-secret-date-labels-3486d73d365081c890ecd2a6992d7879)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:46:01 -07:00
Benjamin Lu
2b010ac8b3 fix: dedupe pending checkout attempt construction (#11622)
## Summary

Deduplicates pending subscription checkout attempt construction so
storage and fallback paths share the same payload creation.

## Changes

- **What**: Build the `PendingSubscriptionCheckoutAttempt` once in
`recordPendingSubscriptionCheckoutAttempt()` and reuse it for
unavailable-storage, failed-write, and successful-write paths.
- **Dependencies**: None.

## Review Focus

This is intended as a no-behavior-change cleanup: unavailable storage
still returns an attempt, failed `setItem()` still returns that attempt
without dispatching, and the pending-checkout event only fires after a
successful storage write.

Linear: FE-209

## Screenshots (if applicable)

N/A

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:28:18 -07:00
jaeone94
b4d209b5f6 feat: refresh missing models through pipeline (#11661)
## Summary

Follow-up to the closed earlier attempt in #11646. This PR keeps the
same user-facing goal, but changes the implementation to reuse the
existing missing model pipeline for refresh instead of maintaining a
separate candidate-only recheck path.

Adds a missing model refresh action in the Errors tab by reusing the
existing missing model pipeline, so users can re-check models after
downloading or manually placing files without reloading the workflow.

## Changes

- **What**:
- Adds `app.refreshMissingModels()` as a reusable refresh entry point
for the current root graph.
- Splits node definition reloading into `app.reloadNodeDefs()` so
missing-model refresh can pull fresh `object_info` without showing the
generic combo refresh success flow.
- Reuses the existing missing model pipeline instead of adding a
separate candidate-only checker. The refresh path serializes the current
graph, reuses active workflow model metadata when available, falls back
to current missing-model metadata, and then reruns the same candidate
discovery/enrichment/surfacing flow used during workflow load.
- Adds missing model refresh state and error handling to
`missingModelStore`.
- Adds a Refresh button next to Download all in the missing model card
action bar.
- Moves Download all from the Errors tab header into the missing model
card, so the Download all and Refresh actions render or hide together.
- Changes Download all visibility from “more than one downloadable
model” to “at least one downloadable model.”
- Keeps the action bar hidden when there are no downloadable missing
models; Cloud still does not render this action area.
- Normalizes active workflow `pendingWarnings` updates so resolved
missing model warnings do not get revived by stale empty warning
objects.
- Adds test IDs and coverage for the new action bar, refresh state,
refresh delegation, pending warning sync, and E2E refresh behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The main design choice is intentionally reusing the missing model
pipeline for refresh instead of implementing a smaller candidate-only
recheck.

The earlier candidate-only approach was cheaper, but it created a
separate source of truth for missing-model resolution and made edge
cases harder to reason about. In particular, it could diverge from the
behavior used when a workflow is loaded, and it did not naturally handle
the case where a model becomes missing after the workflow is already
open. This version pays the cost of refreshing node definitions and
rerunning the missing-model scan for the current graph, but keeps the
refresh behavior aligned with workflow load semantics.

Expected behavior by environment:

- OSS browser:
- The action bar appears when at least one missing model has a
downloadable URL and directory.
  - Download all uses the existing browser download path.
- Refresh reloads `object_info`, refreshes node definitions/combo
values, reruns missing-model detection for the current graph, and clears
the error if the selected model is now available.
- OSS desktop:
- The same action bar appears under the same downloadable-model
condition.
  - Download all uses the existing Electron DownloadManager path.
- Refresh uses the same missing-model pipeline as browser, so manually
placed files or desktop-downloaded files can be rechecked without
reloading the workflow.
- Cloud:
- The action bar remains hidden because model download/import is not
supported in this section for Cloud.

A few boundaries are intentional:

- This PR does not add automatic filesystem watching. Browser OSS cannot
reliably observe local model folder changes, so the user-triggered
Refresh button remains the cross-environment mechanism.
- This PR does not redesign the public `refreshComboInNodes` API beyond
extracting `reloadNodeDefs()` for reuse. Further cleanup of toast
behavior or a more explicit object-info reload API can be follow-up
work.
- This PR keeps refresh scoped to missing-model validation; missing
media and missing nodes continue to use their existing flows.

Linear: FE-417

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772


## Validation

- `pnpm format`
- `pnpm lint` (passes; existing unrelated warning remains in
`src/platform/workspace/composables/useWorkspaceBilling.test.ts`)
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`
- `pnpm build`
- `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true
NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build`
- Manual desktop verification through `~/Projects/desktop` after copying
the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`:
  - confirmed the FE bundle is built with `DISTRIBUTION = "desktop"`
- confirmed missing model Download uses the desktop download path
instead of browser download
- confirmed Refresh can clear the missing model error after the model is
available
- Push hook: `pnpm knip --cache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667)
by [Unito](https://www.unito.io)
2026-04-27 18:53:50 +00:00
Christian Byrne
9a70676c61 [chore] Update Comfy Registry API types from comfy-api@c82adf6 (#11689)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: c82adf6
- Generated on: 2026-04-25T22:16:06Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11689-chore-Update-Comfy-Registry-API-types-from-comfy-api-c82adf6-34f6d73d3650815881e4dae4bdf05696)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-27 18:41:46 +00:00
Christian Byrne
9ce4c18eb1 test: add unit tests for useGLSLRenderer (#11390)
## Summary

Adds 44 unit tests for the `useGLSLRenderer` composable
(`src/renderer/glsl/useGLSLRenderer.ts`), increasing coverage from ~15%
to near-complete line coverage.

## Test Coverage

| Describe Block | Tests | What's Covered |
|---|---|---|
| `init` | 6 | Context creation, FBO setup, `UNPACK_FLIP_Y_WEBGL`, FBO
failure cleanup, post-dispose guard |
| `compileFragment` | 9 | Shader compile success/failure, program link
errors, program re-creation, dispose guard |
| `setResolution` | 4 | Viewport + FBO recreation on resize, no-op on
same size, dispose guard |
| `setFloatUniform` | 2 | Float uniform dispatch, dispose guard |
| `setIntUniform` | 2 | Int uniform dispatch, dispose guard |
| `bindInputImage` | 4 | Texture creation/binding, out-of-range index,
re-bind cleanup, dispose guard |
| `render` | 7 | Draw call, `u_resolution`, fallback textures, final
pass to default FBO, multi-pass ping-pong, dispose/no-program guards |
| `readPixels` | 2 | Returns `ImageData`, throws when uninitialized |
| `toBlob` | 2 | Returns `Blob`, throws when uninitialized |
| `dispose` | 4 | GL resource cleanup, idempotency, `onScopeDispose`,
input texture cleanup |
| `config` | 2 | Default vs custom uniform/input counts |

## Approach

- Mocks `WebGL2RenderingContext`, `OffscreenCanvas`, and `ImageData`
(happy-dom has no WebGL)
- Uses Vue `effectScope` to test `onScopeDispose` cleanup
- Mocks `detectPassCount` from `glslUtils` for multi-pass tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11390-test-add-unit-tests-for-useGLSLRenderer-3476d73d3650815a8787cd9368fd8baf)
by [Unito](https://www.unito.io)
2026-04-27 10:45:37 -04:00
Christian Byrne
56aec1878a test: add unit tests for GPUBrushRenderer (#11388)
## Summary

Add unit tests for `GPUBrushRenderer`, increasing coverage from ~3.5% to
cover constructor initialization, stroke rendering, compositing, preview
blitting, readback, and resource cleanup.

## Changes

- **What**: 27 unit tests for `GPUBrushRenderer` covering all public
methods with comprehensive WebGPU API mocks

## Review Focus

Mock factory approach for WebGPU objects — all GPU globals
(`GPUBufferUsage`, `GPUTextureUsage`, `GPUShaderStage`) are polyfilled
since happy-dom lacks WebGPU support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11388-test-add-unit-tests-for-GPUBrushRenderer-3476d73d3650814ab0e2c0fb8c424faa)
by [Unito](https://www.unito.io)
2026-04-27 10:34:33 -04:00
Dante
502a02213a test: add unit tests for useWorkspaceBilling polling and refresh paths (#11676)
## Summary

Extends `useWorkspaceBilling.test.ts` with five behavioral gaps in the
existing suite: `initialize` does not double-fetch balance when free
tier already has positive balance, `subscribe(planSlug)` forwards
`undefined` for return/cancel URLs, `previewSubscribe` does not refresh
status or balance after success, `pollCancelStatus` falls back to a
default error message when failed status omits `error_message`, and
`pollCancelStatus` halts further scheduled polls when a later poll's API
call rejects.

## Changes

- **What**: Adds 5 Vitest cases across four new `describe` blocks
(`initialize free-tier balance refresh`, `subscribe argument
forwarding`, `previewSubscribe does not refresh state`,
`pollCancelStatus error paths`). Reuses the existing `setupBilling()`
factory and `effectScope` lifecycle.

## Review Focus

- The mid-poll rejection test silences `unhandledRejection` for its
duration: `pollCancelStatus`'s rescheduled poll runs through `void
poll()` inside `setTimeout`, so the catch block's rethrow has no awaiter
and surfaces as unhandled. The test pins the observable behavior (no
further scheduled polls) without claiming `cancelSubscription`
propagates the late error.
- The `subscribe(planSlug)` test pins the call shape with explicit
`undefined` arguments so a future signature change breaks the test
rather than silently passing through.
- The free-tier branch is targeted: previously only the zero-balance
reload was tested; this adds the negative case (positive balance → no
second fetch).

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm format --
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

38 tests pass (33 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11676-test-add-unit-tests-for-useWorkspaceBilling-polling-and-refresh-paths-34f6d73d36508197bc7bc66d54e805e0)
by [Unito](https://www.unito.io)
2026-04-27 10:25:54 -04:00
Dante
f1ea3b02a6 test: add unit tests for useNodeReplacement transfer edge cases (#11677)
## Summary

Extends `useNodeReplacement.test.ts` with five connection-transfer and
graph-mutation edge cases that the existing 23-case suite did not cover:
missing-old-input-slot skip, missing-new-output-index resilience,
set_value on a non-existent widget, set_value with dot-notation new_id,
and the Vue-node refresh path via `nodeGraph.onNodeAdded`.

## Changes

- **What**: Adds 5 Vitest cases in a new `transfer edge cases` describe
block. Reuses the existing `createPlaceholderNode`, `createNewNode`,
`createMockGraph`, `createMockLink`, and `makeMissingNodeType` helpers —
no new test infrastructure introduced.

## Review Focus

- The "missing new output index" test verifies that `replaceWithMapping`
does not throw when `newNode.outputs[newOutputIdx]` is absent, and
asserts the original link's `origin_id` is unchanged so the silent-skip
behavior is pinned (not a swallowed exception).
- The dot-notation `set_value` test pins that the existing dot-notation
guard at `useNodeReplacement.ts:203` covers the `set_value` branch (not
just the `old_id` connection branch already covered at line 187).
- The `onNodeAdded` test asserts the Vue-node sync path that runs after
`replaceWithMapping` bypasses `graph.add()` — a future refactor that
drops the explicit call would silently break the Vue node renderer
otherwise.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm format -- src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

28 tests pass (23 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11677-test-add-unit-tests-for-useNodeReplacement-transfer-edge-cases-34f6d73d3650817aa2ffccdb9fb4a947)
by [Unito](https://www.unito.io)
2026-04-27 10:25:26 -04:00
Christian Byrne
b2bba78ce0 test: add unit tests for usePanAndZoom composable (#11391)
## Summary

Add 29 unit tests for the `usePanAndZoom` composable to improve mask
editor test coverage.

## Changes

- **What**: New test file
`src/composables/maskeditor/usePanAndZoom.test.ts` covering all public
API methods

## Review Focus

Test coverage spans:
- `initializeCanvasPanZoom` — landscape/portrait fit-to-view, panel
width accounting, style application
- `handlePanStart`/`handlePanMove` — panning state, offset updates,
error on missing start
- `zoom` — wheel in/out, clamping (0.2–10.0), missing canvas early
return, cursor update
- `updateCursorPosition` — pan offset application
- `invalidatePanZoom` — missing image warning, store container fallback,
rgbCanvas dimension sync
- Touch handlers — single/two-finger start, double-tap undo, pen
blocking, single-touch pan, pinch zoom, touch end states
- `addPenPointerId`/`removePenPointerId` — add, dedup, remove, no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11391-test-add-unit-tests-for-usePanAndZoom-composable-3476d73d36508104b447d87471ce021b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-27 10:24:44 -04:00
Dante
3d14bfb09c fix: render asset fixtures in AssetBrowserModal stories (#11502)
## Why this fix exists

Surfaced as a dead-end during the FE-227 (asset modal scroll breakage in
cloud-prod) root-cause investigation, **not** as a standalone Storybook
complaint.

The natural local repro path for FE-227 is "bump an `AssetBrowserModal`
Storybook story to ~120 assets and watch the layout misbehave." When
that path was attempted, the stories rendered empty modals regardless of
fixture size. A Codex adversarial review confirmed the cause: three
stories bind `:assets="..."` to a prop the component never declared, so
the binding is silently dropped and the modal falls back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook.

The empty-modal failure mode also silently broke design QA / visual
review on this surface: any reviewer opening these stories has been
seeing "No assets found" for as long as the bug has existed.

Filing this as its own PR so:
- FE-227 stays focused on the cloud-prod scroll bug once a DevTools
datapoint confirms the hypothesis.
- The local repro path for FE-227 (and any future asset-modal layout
regression) becomes usable.
- Visual review on `AssetBrowserModal` is restored.

## What changed

Three `AssetBrowserModal` stories bound `:assets="..."` to a
non-existent prop, so the modal silently fell back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook because the model cache only initializes in cloud distribution
builds. Add an optional `assets` prop on `AssetBrowserModal` that, when
provided, bypasses the store fetch. Production callers continue to use
the store; this is a narrowly scoped Storybook/test seam.

- Fixes FE-232

### Why a prop on the component (Option 1) and not a Storybook decorator
(Option 2)

`assetsStore`'s model cache (`getModelState`) is gated by `if
(isCloud)`, returning an empty stub for desktop/localhost distributions.
Storybook's `.storybook/main.ts` does not define `__DISTRIBUTION__`, so
`isCloud === false` and the store has no public API to seed assets.
Public seed methods (`updateModelsForNodeType` / `updateModelsForTag`)
only delegate to `assetService` network calls. Option 2 (decorator-based
seeding) would require either patching Storybook's Vite `define` config
or building a parallel mock store via `resolve.alias` (the `useJobList`
precedent) — significantly more invasive than a +10 / -1 line component
change. The new prop is documented as a Storybook/test seam in JSDoc and
changes nothing for production callers.

### Bonus

`useAssetBrowserDialog.stories.ts:120` had the **same** broken
`:assets="mockAssets"` binding. The new prop transparently repairs it
without a separate change.

## Before

All three stories render an empty modal (`No assets found`) regardless
of the fixture data they pass.

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `before-default.png` → Default story
> - `before-single-asset-type.png` → Single asset type story
> - `before-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
| Default | |
<img width="961" height="821" alt="before-default"
src="https://github.com/user-attachments/assets/4a0af0f5-b712-41e2-adbc-c2b4b921045d"
/>

| Single asset type | |
<img width="961" height="821" alt="before-single-asset-type"
src="https://github.com/user-attachments/assets/073a8fa8-7bbb-4ec9-a226-156b7141d9b5"
/>

| No left panel | <img width="961" height="821"
alt="before-no-left-panel"
src="https://github.com/user-attachments/assets/0d45ff3b-5866-4de9-b7aa-5bd9cb1f3566"
/> |

## After

All three stories now render their intended fixture data (asset cards
visible with mock model names, badges, sort/filter controls populated).

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `after-default.png` → Default story
> - `after-single-asset-type.png` → Single asset type story
> - `after-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
<img width="961" height="821" alt="after-default"
src="https://github.com/user-attachments/assets/a11b2475-bd18-4c30-aece-cf1bdbcc6ac5"
<img width="961" height="821" alt="after-single-asset-type"
src="https://github.com/user-attachments/assets/71e11237-006b-43d9-90de-e9d2d8894e34"
/>
 />

| Default |  |
| Single asset type |   |
| No left panel | <img width="961" height="821"
alt="after-no-left-panel"
src="https://github.com/user-attachments/assets/5123db87-2ab9-4359-8e61-ac0d8da9494c"
/>
  |


## Test plan

- [x] Storybook stories now render fixture data (manually verified all
three via Chrome DevTools MCP)
- [x] `pnpm typecheck` passes on touched files
- [x] `pnpm lint` passes on touched files
- [x] Existing `AssetBrowserModal.test.ts` (14 tests) still passes
- [x] `useAssetBrowserDialog.stories.ts` is also functional (same bug
pattern, repaired by the new prop)
- [ ] No new prop surface added to `AssetBrowserModal` other than the
documented Storybook/test seam (`assets?: AssetItem[]`)
2026-04-27 11:30:59 +00:00
Dante
3340b77908 test: add unit tests for value-control widget family (#11440)
## Summary

Adds 42 unit tests across 5 files covering the value-control widget
family — first batch of a broader effort to raise widget-test coverage.

## Changes

- **What**:
- `WidgetInputNumber.test.ts` (9) — variant selection by widget.type
(int/float/slider/gradientslider), controlWidget wrapping in
WidgetWithControl, modelValue forwarding.
- `WidgetInputNumberGradientSlider.test.ts` (11) — initial value,
min/max/disabled pass-through, default vs custom gradient stops,
precision-derived step, WidgetLayoutField wrapping.
- `WidgetWithControl.test.ts` (5) — renders passed component with
widget/modelValue, initializes ValueControlButton mode from
widget.controlWidget.value, calls controlWidget.update on mode change.
- `ValueControlButton.test.ts` (11) — i18n aria-label per mode, text vs
icon rendering, pointer and keyboard activation, `type="button"` safety.
- `ValueControlPopover.test.ts` (6) — BEFORE/AFTER copy from
settingStore, four option render, v-model updates on selection.

## Review Focus

- Stack follows the existing widget-test pattern (`@testing-library/vue`
+ PrimeVue + `createI18n` where needed, no `@vue/test-utils`).
- `createMockWidget` from `widgetTestUtils.ts` reused; no new helper
extracted (YAGNI — per-file `renderComponent` stays ~10 lines).
- `WidgetWithControl` watcher test asserts first-arg of `update` since
the vue watch callback passes `(newVal, oldVal, onCleanup)`.
- No changes to any widget component source — tests-only PR.

This is one of several focused PRs in a widget-test-coverage sequence;
subsequent PRs cover form-dropdown internals, utility widgets,
media/graph/canvas widgets, and e2e value-type specs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11440-test-add-unit-tests-for-value-control-widget-family-3486d73d3650813891e1fe8d45eaecaf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-27 20:32:31 +09:00
Dante
0ad85087ea test: add unit tests for useMissingModelInteractions (#11675)
## Summary

Extends `useMissingModelInteractions.test.ts` with behavioral coverage
for the previously untested public surface: `getComboOptions` (both
asset-supported and widget-driven branches), `getDownloadStatus`, and
the four `handleImport` outcomes (async-pending, async-completed,
sync-with-mismatch, error).

## Changes

- **What**: Adds 10 Vitest cases across three new `describe` blocks
(`getComboOptions`, `getDownloadStatus`, `handleImport`). Extends the
existing module-level mocks with `mockUploadAssetAsync`,
`mockTrackDownload`, and `mockInvalidateModelsForCategory` so the import
flow can be verified at its boundaries.

## Review Focus

- The `handleImport` block uses a shared `setupImportableState(key)`
helper to seed `urlInputs` + `urlMetadata` and stub `validateSourceUrl`
once per test. Each case then asserts a single boundary effect (taskId
tracked, cache invalidated, mismatch recorded, error stored).
- The `getDownloadStatus` happy path relies on the existing getter-style
`mockDownloadList` so the test's mutation lands in the composable
without re-stubbing the asset download store.
- The `getComboOptions` "asset-supported" test asserts both the call
shape (`mockGetAssets` invoked with the candidate's `nodeType`) and the
output shape, so a future refactor that swaps the lookup key fails
loudly.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm format --
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

44 tests pass (34 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11675-test-add-unit-tests-for-useMissingModelInteractions-34f6d73d36508112909fe8e49cc68010)
by [Unito](https://www.unito.io)
2026-04-27 06:21:49 -04:00
pythongosssss
4c892341e4 feat: Node search UX updates (#9714)
## Summary

Addresses feedback from the initial v2 node search implementation for
improved UI and UX

## Changes

- **What**: 
- add root filter buttons
- remove all extra tree categories leaving only "Most relevant"
- replace input/output selection with popover
- replace price badge with one from node header
- add chevrons and additional styling to category tree
- hide empty categories
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- keyboard navigation
- general tidy/refactor/test

## Screenshots (if applicable)

https://github.com/user-attachments/assets/db798dfa-e248-4b48-bb56-2fa7b6c5f65f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9714-feat-Node-search-UX-updates-31f6d73d365081cebd96c4253ad1ca53)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:47:47 +00:00
Christian Byrne
eb8f8b75b5 fix: correct zh-CN translations across landing, product, and pricing pages (#11655)
*PR Created by the Glary-Bot Agent*

---

## Summary

Applies translation corrections to the Chinese (zh-CN) version of the
website, requested in the `#project-new-website-brand-refresh` Slack
thread by a native-speaker reviewer.

### Changes (in `apps/website/src/i18n/translations.ts` +
`apps/website/src/pages/zh-CN/index.astro`)

- **Landing — blog section badges**: `如何` → `了解`, `运作` → `运行方式`
- **Landing — hero headline**: `专业控制` → `最强可控性` (also updates the zh-CN
page `<title>`)
- **Landing — hero subtitle**: `Comfy 是面向视觉专业人士的 AI
创作引擎,让您掌控每个模型、每个参数和每个输出。` → `Comfy 是面向专业视觉人士的 AI
创作引擎。您可以精确掌控每个模型、每个参数和每个输出。`
- **Landing — showcase subtitle**: `从社区模板开始,或从零构建。` → `从工作流模板开始,或从零构建。`
- **Landing — showcase feature1 title**: `节点式完全控制` → `节点带来的可控性`
- **Landing — use case body**: `由 60,000+ 节点、数千个工作流 和一个比任何公司都更快构建的社区驱动。`
→ `60,000+ 节点,数千条工作流,一个比任何公司速度都更快的社区。`
- **All `查看xx特性` CTAs** (local / cloud / API / enterprise product
cards): `特性` → `属性`
- **All `合作节点` pricing copy**: `合作节点` → `合作伙伴节点` (correct translation
for "partner node")

### Verification

- `pnpm typecheck` (astro check): 0 errors, 0 warnings, 0 hints
- `pnpm build`: completes successfully; rebuilt zh-CN pages contain the
new strings and no longer contain the old ones
- Manual Playwright verification on `/zh-CN/` (hero, showcase badges,
showcase feature card, use-case body, product cards) and
`/zh-CN/cloud/pricing/` (`合作伙伴节点` appears 4×, `合作节点` appears 0×)

### Notes on review feedback

A review pass flagged two of the swaps (`特性→属性` and `从社区模板→从工作流模板`) as
potential style concerns. Both changes are kept as-is because they were
specified verbatim by the native-speaker reviewer who requested this
pass.

## Screenshots

![zh-CN landing hero showing new headline and rewritten
subtitle](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526109-4a011f24-da46-4b6b-ba01-bce9cc8aec63.png)

![zh-CN showcase section showing updated badges, workflow template
subtitle, and node controllability feature
card](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526507-3054b3d7-0950-4c09-8edc-643c913ca1dc.png)

![zh-CN use-case section showing reworded 60,000+ node
copy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526822-772cd907-0a70-441b-ac0c-bef2d1eb0f9f.png)

![zh-CN product cards showing updated
CTAs](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246527170-d1969308-5e6c-4d8f-bfee-24e31b0d0670.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11655-fix-correct-zh-CN-translations-across-landing-product-and-pricing-pages-34e6d73d3650816daddde4a90fb47a01)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 06:18:47 +00:00
Dante
c594e30b84 test: harden useKeyboard test setup with vi.hoisted and try/finally (#11659)
## Summary
- Move `mockCanvasHistory` / `mockStore` into `vi.hoisted()` so the mock
state is hoisted before module imports, matching the pattern in
`useCanvasTransform.test.ts`.
- Wrap the temporary `document.activeElement` override in `try/finally`
so the property is restored even if the assertion throws, preventing
state leak into subsequent tests.

- Fixes #11658

## Test plan
- [x] `pnpm test:unit src/composables/maskeditor/useKeyboard.test.ts` —
17/17 pass
- [x] `pnpm typecheck`
- [x] `pnpm lint` (no new warnings)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11659-test-harden-useKeyboard-test-setup-with-vi-hoisted-and-try-finally-34f6d73d36508139be2ddc3095ea6952)
by [Unito](https://www.unito.io)
2026-04-27 03:26:33 +00:00
Dante
2ade779a81 fix: use getAssetFilename in asset browser to avoid showing hashes (#11492)
## Root cause
Three surfaces rendered `asset.name` directly even though in Cloud
production `asset.name` often equals `asset.asset_hash`:

1. **Asset browser modal** (`AssetCard.vue`) used `getAssetDisplayName`,
which — when `user_metadata.name` also held the hash — fell through to
the raw hash.
2. **Load Image node widget dropdown** (`useWidgetSelectItems.ts`)
rendered output items as `${asset.name} [output]`.
3. Queue-mapped output assets (`mapTaskOutputToAssetItem`) populate the
human-readable filename only on `asset.display_name`, not on
`user_metadata.filename` / `metadata.filename`. So surfaces that rely
only on `getAssetFilename` still fall through to `asset.name` (the
hash).

Filename / title resolution is now split into three helpers with
distinct responsibilities:

- `getAssetFilename` (unchanged) — canonical filename for serialization
/ identifier use (workflow widget values, schema validation,
missing-model matching). Never substitutes a display-only string.
- `getAssetDisplayFilename` (new) — filename-first label for surfaces
that render a filename. Adds `asset.display_name` as a fallback before
`asset.name`. Used by the Load Image output dropdown.
- `getAssetCardTitle` (new) — card title / delete-dialog label. Prefers
`user_metadata.name` / `metadata.name` when distinct from `asset.name`
(preserves user-renamed model titles from `ModelInfoPanel`), and falls
through to `getAssetDisplayFilename` for the Cloud hash case.

The dropdown item's `name` field (workflow payload value) is still
`${asset.name} [output]`, so Cloud can continue to resolve the asset by
hash.

Fixes FE-228

Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

## Red / green verification
| Step | SHA | Purpose |
| --- | --- | --- |
| Red (card — metadata.filename) | `20b32e4f0` | `AssetCard.test.ts`
asserts the rendered title is the human-readable filename when
`asset.name === asset_hash`. |
| Green (card) | `ea889b34c` | `AssetCard.vue` switches from
`getAssetDisplayName` to `getAssetFilename`. |
| Red (widget — metadata.filename) | `318feddec` | Asserts the output
dropdown `label` uses `metadata.filename` when `asset.name` is a hash. |
| Green (widget) | `7b19bde15` | `useWidgetSelectItems.ts` derives
`label` from `getAssetFilename`; `name` (serialized) stays
`\${asset.name} [output]`. |
| Red (widget — display_name path) | `b19716e60` | Failing test for the
queue-mapped shape (`display_name` populated, `user_metadata.filename`
absent). |
| Green (util, reverted) | `533e60d6a` | Initial attempt broadened
`getAssetFilename` to include `display_name`; altered filename semantics
for model-asset consumers. |
| Refactor (scope narrowing) | `7c1085f30` | Reverts the util change;
applies `display_name` fallback locally in the output dropdown only. |
| Red (card — display_name path) | `38a9d4828` | Failing test: AssetCard
must fall back to `display_name` when filename metadata is absent. |
| Green (helper split) | `4ca0f620f` | Introduces
`getAssetDisplayFilename`; swaps AssetCard + widget dropdown to use it.
Adds helper unit tests. |
| Red (card — preserves curated name) | `dc2e9231d` | Failing test: a
user-curated `user_metadata.name` distinct from `asset.name` must win
over the filename; plus non-regression guard that
curated-name-equal-to-hash still falls back to filename. |
| Green (card title helper) | `5decf3a2b` | Adds `getAssetCardTitle`
(curated name when distinct, else `getAssetDisplayFilename`). AssetCard
title + delete-dialog swap to it; widget dropdown stays on
`getAssetDisplayFilename`. |

Side-effect audit (`getAssetFilename` still canonical):
- `createModelNodeFromAsset.ts`, `createAssetWidget.ts`,
`missingModelScan.ts`, `useComboWidget.ts`, `useWidgetSelectItems.ts`
asset-mode `name` — all still resolve through `getAssetFilename`, so
model-asset widget serialization and missing-model matching are
unaffected.

## Screenshots

### As is — asset browser card

<img width="1269" height="899" alt="Screenshot 2026-04-21 at 10 49 41
AM"
src="https://github.com/user-attachments/assets/7cffb585-4e64-4037-8bb1-5dd40215597e"
/>

### To be

<img width="1145" height="533" alt="Screenshot 2026-04-25 at 7 25 17 PM"
src="https://github.com/user-attachments/assets/8f12388a-16df-4892-83b4-c8d1f033f190"
/>


_Verified locally via `pnpm dev:cloud`: asset browser cards preserve
user-curated display names, Cloud-hash cases render the filename, and
the Load Image output dropdown also renders the human-readable filename.
The selected value still serializes the hash path._

## Test plan
- [ ] \`pnpm test:unit -- AssetCard.test\` passes (4 cases:
metadata.filename, display_name fallback, curated-name preservation,
curated-name==hash fallback)
- [ ] \`pnpm test:unit -- assetMetadataUtils.test\` passes (53 cases
incl. new helper coverage)
- [ ] \`pnpm test:unit -- useWidgetSelectItems.test\` passes (26 cases)
- [ ] \`pnpm test:unit -- useMissingModelInteractions.test\` passes
(guards against model-asset regressions)
- [ ] Asset browser modal: user-renamed model shows curated name; Cloud
hash outputs show filename
- [ ] Load Image node → widget dropdown → Outputs tab shows original
filenames
- [ ] Delete-confirm dialog references the same curated name as the card
title
- [ ] Model-asset widgets (Checkpoint / LoRA / etc.) still serialize the
model filename
- [ ] Submitting a workflow that references a Cloud output still
executes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11492-fix-use-getAssetFilename-in-asset-browser-to-avoid-showing-hashes-3496d73d36508148b8a3fb0482fa668e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 03:16:18 +00:00
Dante
25f493bd30 test(assets): add E2E spec for sort options (#11634)
## Summary

Adds 6 @cloud-tagged Playwright tests covering the assets sidebar sort
menu (newest/oldest/longest/fastest). Phase 4 of the assets-test plan.

**Stacked on #11632** — base will retarget to \`main\` once the
foundation PR merges. Independent of #11633 (filter E2E) and can be
reviewed in parallel.

## Changes

- **What**: New file `browser_tests/tests/sidebar/assets-sort.spec.ts`.
Uses the `createJobsWithExecutionTimes()` factory and the
`sortLongestFirst` / `sortFastestFirst` locators added in #11632.
- **Coverage**:
  - Settings menu exposes all four sort options in cloud mode
  - Default order is newest first (descending `create_time`)
  - "Oldest first" reverses the order
  - "Longest first" puts the slowest execution at the top
  - "Fastest first" puts the quickest execution at the top
  - Sort persists across search-input edits
- **Breaking**: none

## Review Focus

- **Misaligned (create_time, duration) axes**: fixture data is
deliberately constructed so newest/oldest and longest/fastest produce
distinct orderings — no test can false-pass by satisfying a different
sort. See the table comment at the top of the spec.
- **`@cloud` tag is required**: sort options are gated behind
`:show-sort-options="isCloud"`, which depends on the compile-time
`__DISTRIBUTION__` flag. Tests run only against the `cloud` Playwright
project.
- **Local verification needed**: maintainer should verify with `pnpm
dev:cloud` + `pnpm test:browser:local --project cloud --grep "sort
options"` before merging — I could not run the cloud dev server
end-to-end in my environment.

Fixes #10779

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11634-test-assets-add-E2E-spec-for-sort-options-34e6d73d365081a79facde5bde2e18c6)
by [Unito](https://www.unito.io)
2026-04-27 12:26:08 +09:00
Comfy Org PR Bot
b8b5e1ec1f 1.44.11 (#11656)
Patch version increment to 1.44.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11656-1-44-11-34f6d73d3650812aa813ee9efb11dced)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-27 02:14:50 +00:00
Terry Jia
59ef69f355 test: add unit tests for useCoordinateTransform mask editor composable (#11640)
## Summary

Add unit tests for `useCoordinateTransform` mask editor composable,
raising coverage from 2.43% to 100% (statements / branches / functions /
lines).

## Changes

- **What**: Add
`src/composables/maskeditor/useCoordinateTransform.test.ts` (14 tests)
covering both `screenToCanvas` and `canvasToScreen`: identity (display
matches bitmap), uniform downscale (bitmap larger than display),
`pointerZone`-vs-`canvasContainer` offset, non-uniform per-axis scaling,
screen↔canvas round-trip, and the three "element missing" branches
(`pointerZone` / `canvasContainer` / `maskCanvas` null) that should warn
and return `{x:0,y:0}`.

## Review Focus

- Mocked `createSharedComposable` to a pass-through so each test gets a
fresh transform reading the latest `mockStore` refs (otherwise the
shared instance captures stale element references between tests).
- DOM rects are stubbed via `vi.spyOn(el, 'getBoundingClientRect')`
rather than constructing fake DOMRects, so `unref(...)` in the
composable still receives a real `HTMLElement` / `HTMLCanvasElement`.
- Round-trip test (`screenToCanvas` → `canvasToScreen`) verifies the two
functions are mathematical inverses under the offset + scale
combination, which is the actual invariant the rest of the editor relies
on.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by public method, explicit `MockStore` type alias, helper
factories `createElementWithRect` / `createCanvasWithRect`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11640-test-add-unit-tests-for-useCoordinateTransform-mask-editor-composable-34e6d73d3650814d95bdef66e36328e8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:41 -04:00
Terry Jia
9ad052467d test: add unit tests for useKeyboard mask editor composable (#11639)
## Summary

Add unit tests for `useKeyboard` mask editor composable, raising
coverage from 0% to 100% (statements/lines/functions, 95.65% branch).

## Changes

- **What**: Add `src/composables/maskeditor/useKeyboard.test.ts` (17
tests) covering key tracking (`isKeyDown`), space-key
blur/preventDefault, undo/redo shortcuts (Ctrl/Meta+Z, Ctrl+Shift+Z,
Ctrl+Y), modifier-key edge cases (Alt suppression, no-modifier no-op,
Ctrl+Shift+Y ignored), `window blur` clearing keys, and listener
teardown via `removeListeners`.

## Review Focus

- Mock surface is intentionally minimal — only `useMaskEditorStore` is
mocked because the composable only reaches
`store.canvasHistory.{undo,redo}`.
- `afterEach(keyboard.removeListeners)` is required: the composable
attaches listeners to `document` / `window`, so without teardown earlier
test instances leak handlers and inflate mock call counts in later
tests.
- Tests dispatch real `KeyboardEvent`s via `document.dispatchEvent`
rather than calling the internal handlers directly, so they exercise the
actual `addEventListener` wiring.
- Test style aligned with existing mask editor tests: `should ...`
naming, `describe` grouped by public method, explicit `MockStore` /
`MockCanvasHistory` type aliases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11639-test-add-unit-tests-for-useKeyboard-mask-editor-composable-34e6d73d36508129b437d0270d9424d8)
by [Unito](https://www.unito.io)
2026-04-26 22:02:19 -04:00
Dante
aa730c8cb5 test(assets): add unit tests and E2E fixture groundwork for sidebar filter & sort (#11632)
## Summary

Lays the test foundation for the two open assets-testing issues — Phase
1 (fixtures + page object) and Phase 2 (unit tests). Phase 3/4 (the
actual E2E specs) follow in stacked PRs.

## Changes

- **What**: 27 new unit tests covering `useMediaAssetFiltering`,
`MediaAssetFilterMenu`, and `MediaAssetSettingsMenu`; reusable
Playwright fixture factories for diverse media kinds and execution-time
specs; new locators + helpers on `AssetsSidebarTab` for the filter menu
and longest/fastest sort options. No production code touched.
- **Breaking**: none

### New files
- `src/platform/assets/composables/useMediaAssetFiltering.test.ts` — 11
cases: single/multi-OR media-type filter, `'3D'` → `'3d'` filename
normalization, exclusion of unsupported kinds, all four sort modes,
`created_at` fallback when `user_metadata.create_time` is absent,
filter+sort composition.
- `src/platform/assets/components/MediaAssetFilterMenu.test.ts` — 6
cases: checkbox rendering, prop-driven `aria-checked`, click toggling
(add/remove/append), keyboard activation (Enter/Space).
- `src/platform/assets/components/MediaAssetSettingsMenu.test.ts` — 10
cases: view-mode v-model, `showSortOptions` and `showGenerationTimeSort`
visibility gates, `v-model:sortBy` round-trip for
newest/oldest/longest/fastest.

### Extended files
- `browser_tests/fixtures/helpers/AssetsHelper.ts` — added
`MediaKindFixture` type, optional `mediaKind` shorthand on
`createMockJob` (sets both filename extension and
`preview_output.mediaType`), plus `createMixedMediaJobs(kinds)` and
`createJobsWithExecutionTimes(specs)` factories for unambiguous
filter/sort assertions.
- `browser_tests/fixtures/components/SidebarTab.ts` — added
`filterButton`, per-type checkbox locators
(`filterImage/Video/Audio/3DCheckbox`), `sortLongestFirst`,
`sortFastestFirst`, plus `openFilterMenu()`, `filterCheckbox(kind)`,
`toggleMediaTypeFilter(kind)`, and `getAssetCardOrder()` helpers.

## Review Focus

- **Naming of `MediaKindFixture` values** — `'images'` is plural to
match existing API conventions emitted by the backend /
`useMediaAssetGalleryStore`; `'video' | 'audio' | '3D'` follow the
singular `MediaKind` type. Open to renaming if a unified shape is
preferred.
- **Filter button locator strategy** — `MediaAssetFilterButton` has no
`aria-label`, so the page object targets it via the
`icon-[lucide--list-filter]` class. Happy to add a `data-testid` or
`aria-label` to the source component if reviewers prefer a more durable
hook (would be a one-line source change in a follow-up).

## Follow-up PRs

- Phase 3 (E2E for media-type filter) → closes #10780
- Phase 4 (E2E for asset sort) → closes #10779

Both are stacked on this branch and can be reviewed/merged in either
order once this lands.

References #10779, #10780.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11632-test-assets-add-unit-tests-and-E2E-fixture-groundwork-for-sidebar-filter-sort-34e6d73d3650815c9900e5fd7cc7eab0)
by [Unito](https://www.unito.io)
2026-04-27 01:46:50 +00:00
Terry Jia
cc1fe65348 test: add unit tests for maskEditorDataStore (#11641)
## Summary

Add unit tests for `maskEditorDataStore` Pinia store, raising coverage
from 0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorDataStore.test.ts` (13 tests)
covering initial state, the three `computed` predicates
(`hasValidInput`, `hasValidOutput`, `isReady` including the `isLoading`
interaction), the `setLoading(loading, error?)` action across its three
branches (no error arg, truthy error arg, empty-string error arg — empty
string is falsy so it must NOT clobber an existing `loadError`), and
`reset()` clearing every field including derived predicates.

## Review Focus

- Uses `createTestingPinia({ stubActions: false })` so action
implementations actually run, matching the pattern used by other store
tests in `src/stores/` (e.g. `dialogStore.test.ts`).
- The `setLoading('', '')` test guards a real branch in the source — `if
(error)` skips assignment for empty strings, so callers can't
accidentally clear a previous error by passing `''`. Worth keeping if
anyone tightens the guard later.
- `reset()` test asserts both raw refs and the three computed values
flip back to `false` / `null`, so a future regression in either
direction is caught.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed property/action, `createImage` / `createCanvas` /
`createOutputData` helpers to keep arrange blocks short.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11641-test-add-unit-tests-for-maskEditorDataStore-34e6d73d36508121b8e5d185178310ab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 21:38:38 -04:00
Terry Jia
0f66f76b87 test: add unit tests for mask editor control components (#11642)
## Summary

Add unit tests for the three mask editor control components
(`DropdownControl`, `SliderControl`, `ToggleControl`), raising each from
partial (20% / 37.5% / 44.4%) to 100% across all coverage dimensions.

## Changes

- **What**: Add three sibling test files under
`src/components/maskeditor/controls/`:
- `DropdownControl.test.ts` (5 tests): label render, normalization of
`string[]` options into `{label,value}`, pass-through of `{label,value}`
options, `modelValue` reflected as the selected option,
`update:modelValue` emitted on change.
- `SliderControl.test.ts` (4 tests): label render,
`min`/`max`/`step`/`modelValue` exposed on `<input type="range">`,
`step` defaults to `1` when omitted, `update:modelValue` emitted as a
`number` (not string) on input.
- `ToggleControl.test.ts` (5 tests): label render, `modelValue`
reflected as `checked`, `update:modelValue` emitted as `true` / `false`
on toggle.

## Review Focus

- Stack matches the project's prevailing Vue test pattern
(`@testing-library/vue` + `@testing-library/user-event`, e.g.
`Badge.test.ts`, `BatchCountEdit.test.ts`).
- `update:modelValue` is asserted via the `'onUpdate:modelValue'`
callback prop rather than `emitted()` — keeps tests focused on
observable behavior and avoids reaching into the wrapper.
- `SliderControl` uses `fireEvent.input` instead of
`userEvent.type/clear`: `<input type="range">` is non-editable for
`userEvent` (`clear() is only supported on editable elements`). The
single eslint-disable for `testing-library/prefer-user-event` is
annotated with the reason.
- `DropdownControl` test guards both branches of the `string` →
`{label,value}` normalization (string array path and pre-normalized
object array path), since that's the only meaningful logic in the file.
- Style aligned with sibling tests: `should ...` naming, per-file
`renderComponent` helper accepting a `props` override and an `onUpdate`
callback parameter.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11642-test-add-unit-tests-for-mask-editor-control-components-34e6d73d3650812aba2ae34a760489e2)
by [Unito](https://www.unito.io)
2026-04-26 21:38:16 -04:00
Terry Jia
bc16865019 test: add unit tests for useToolManager mask editor composable (#11643)
## Summary

Add unit tests for `useToolManager` mask editor composable, raising
coverage from 0% to 100% (statements / functions / lines, 97.53%
branch).

## Changes

- **What**: Add `src/composables/maskeditor/useToolManager.test.ts` (35
tests) covering:
- `switchTool`: store update, layer auto-switch via
`newActiveLayerOnSet`, custom-cursor branch, no-cursor (default
`'none'`) branch, missing-`pointerZone` no-throw guard.
- `setActiveLayer`: rgb-while-mask-only-tool → swap to `PaintPen`,
mask-while-`PaintPen` → swap to `MaskPen`, no-swap path.
- `updateCursor`: same custom-cursor / default-cursor split plus
`brushPreviewGradientVisible = false` post-condition.
- `currentTool` watcher: clears `lastColorSelectPoint` only when leaving
`MaskColorFill`.
- `handlePointerDown`: touch-ignore, pen pointer registration,
middle-button pan, space+left pan, `MaskPen`/`PaintPen` left-button
drawing, `PaintPen` continue-drawing branch (`button !== 0 && buttons
=== 1`), `MaskBucket` flood fill (with coord transform),
`MaskColorFill`, alt+right brush adjustment, right-click drawing for
drawing tools, no-op for non-drawing tools.
- `handlePointerMove`: touch-ignore, cursor position update,
middle-button pan, space+left pan, non-drawing-tool ignore, alt+right
brush adjustment while `isAdjustingBrush`, left/right drag drawing.
- `handlePointerUp`: state cleanup (`isPanning` / `brushVisible` /
`isAdjustingBrush`), pen pointer removal, touch-pointer early bail
before `drawEnd`.

## Review Focus

- Mock store is wrapped in `reactive()` so the `watch(() =>
store.currentTool, ...)` actually fires when tests mutate `currentTool`.
Plain object mocks would silently no-op the watcher branch.
- Each `setup()` runs `useToolManager` inside its own `effectScope`,
stopped in `afterEach`. Without scoping, watchers from previous tests
stay attached to the shared reactive store and accumulate (a single
mutation in test N would call `clearLastColorSelectPoint` N times).
- Mocked `app.extensionManager.setting.get` because `useBrushDrawing`
factory reads two settings synchronously at construction time. The mock
returns deterministic defaults so we don't need `useSettingStore`
plumbing.
- Pointer-event factory builds the minimal shape (`button` / `buttons` /
`pointerType` / `offset*` / `client*` / `altKey` / `pointerId`) — no
jsdom `PointerEvent` constructor noise. `preventDefault` is a `vi.fn()`
because the source calls it unconditionally.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function/watcher, typed `MockStore`, helper
`pointerEvent({ ... })` and `setup()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11643-test-add-unit-tests-for-useToolManager-mask-editor-composable-34e6d73d36508184b017ebd04626b29d)
by [Unito](https://www.unito.io)
2026-04-26 20:49:01 -04:00
Terry Jia
206a367379 test: add unit tests for useMaskEditor composable (#11644)
## Summary

Add unit tests for `useMaskEditor` composable, raising coverage from 0%
to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/composables/maskeditor/useMaskEditor.test.ts` (7
tests) covering `openMaskEditor`:
- Happy path: dialog opened once, `node` forwarded as a prop, header /
content components attached.
- Modal dialog config (`modal` / `maximizable` / `closable` flags)
forwarded to PrimeVue dialog props.
- Acceptance path for nodes with no `imgs` but `previewMediaType ===
'image'`.
- Three guard paths that should log and bail: `node` is null, node with
empty `imgs` and no image preview, node with empty `imgs` and a
non-image preview type (e.g. `'video'`).

## Review Focus

- Mocked `useDialogStore` with a single shared `showDialog` spy — the
only contract under test is "we forwarded these props to the store
action", so instantiating Pinia would just add noise.
- `TopBarHeader.vue` and `MaskEditorContent.vue` are stubbed because
they pull in the full mask-editor render tree; we only assert they're
forwarded as `headerComponent` / `component`, not what they render.
- `console.error` is spied per-test so the bail messages are observable
but don't pollute runner output.
- `nodeWithImage` factory uses a structural `NodeShape` (`{ imgs?,
previewMediaType? }`) rather than `Partial<LGraphNode>` because the real
`LGraphNode` type requires a `LGraphNodeConstructor`-shaped
`constructor` field, which would force every test to construct a full
graph node — irrelevant to the contract being tested.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by exposed function (`openMaskEditor`), helper factory.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11644-test-add-unit-tests-for-useMaskEditor-composable-34e6d73d365081e98336db0a92c37ccf)
by [Unito](https://www.unito.io)
2026-04-26 20:48:28 -04:00
Terry Jia
7e8ede376b test: add unit tests for maskEditorStore (#11645)
## Summary

Add unit tests for `maskEditorStore` Pinia store, raising coverage from
0% to 100% (statements / branches / functions / lines).

## Changes

- **What**: Add `src/stores/maskEditorStore.test.ts` (30 tests)
covering:
- Brush setters: `setBrushSize` (1–250), `setBrushOpacity` (0–1),
`setBrushHardness` (0–1), `setBrushStepSize` (1–100) — each tested at
lower bound, upper bound, and in-range.
  - `resetBrushToDefault`: documents the exact default brush shape.
- Other clamped setters: `setPaintBucketTolerance` /
`setColorSelectTolerance` / `setMaskTolerance` (0–255), `setFillOpacity`
/ `setSelectionOpacity` (0–100), `setMaskOpacity` (0–1), `setZoomRatio`
(0.1–10).
- `setPanOffset` / `setCursorPoint`: copy-by-value semantics — mutating
the input after the call must not leak into store state.
  - `resetZoom` / `triggerClear`: monotonic counter bumps.
- `maskColor` computed: `Black`, `White`, `Negative` blend modes plus
the `default:` fallback for unknown values.
  - `canUndo` / `canRedo` proxy through to mocked `useCanvasHistory`.
- Canvas → ctx watchers: setting `maskCanvas` / `rgbCanvas` /
`imgCanvas` derives the corresponding `*Ctx` via `getContext('2d', {
willReadFrequently: true })`. Clearing the canvas leaves the previous
ctx in place (parametrized via `it.each` for all three).
- `resetState`: restores all non-DOM state to documented defaults;
explicitly verifies DOM refs (`maskCanvas` / `pointerZone` / `image`)
are NOT cleared so the editor can reuse mounted elements after a reset.

## Review Focus

- `useCanvasHistory` is mocked via `vi.hoisted` so each test gets the
same exposed `canUndo` / `canRedo` refs while the store's internal
`canvasHistory` reference is untouched. Without this, the store would
call into the real history with `null` canvas refs.
- `setPanOffset` / `setCursorPoint` tests mutate the input *after* the
call — that's the actual behavioral contract (defensive copy via
spread), not a default-value check.
- `resetState` test sets *every* field to a non-default before calling,
so the test fails if `resetState` ever forgets to reset a field. Final
assertions are positive (matches default), not weak negative checks.
- The "DOM refs preserved on reset" assertion is the
surprising-on-purpose part: a future refactor that adds
`maskCanvas.value = null` to `resetState` would break the editor's
ability to reuse mounted canvases after clearing internal state.
- `it.each` for the three canvas/ctx pairs covers the watcher's null
branch without three near-duplicate tests.
- `makeCanvas` overrides `canvas.getContext` directly rather than using
`vi.spyOn` because `HTMLCanvasElement.getContext` has overloads (2d /
webgl / webgpu / bitmaprenderer) and TypeScript picks the GPU overload
by default for spy return type inference.
- Style aligned with sibling `maskEditorDataStore.test.ts`:
`createTestingPinia({ stubActions: false })`, `should ...` naming,
`describe` grouped by exposed property/action, no default-only
change-detector tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11645-test-add-unit-tests-for-maskEditorStore-34e6d73d3650818e9855cd9f9f13e62a)
by [Unito](https://www.unito.io)
2026-04-26 20:47:58 -04:00
Terry Jia
7bfbd0d7f3 test: add unit tests for mask editor settings panels (#11647)
## Summary

Add unit tests for the five mask editor settings panel components,
raising each from 0–35% to ~98–100% across coverage dimensions.

## Changes

- **What**: Add 5 sibling test files under `src/components/maskeditor/`
(47 tests total):
- `PaintBucketSettingsPanel.test.ts` (4): both sliders bind to / write
through to `setPaintBucketTolerance` / `setFillOpacity`. **100%**.
- `ColorSelectSettingsPanel.test.ts` (8): all 7 controls (4 sliders, 2
toggles, 1 dropdown) bind to and write through to the right store
fields/setters; method dropdown casts to `ColorComparisonMethod`.
**100%**.
- `BrushSettingsPanel.test.ts` (13): shape buttons (Arc/Rect), reset to
default, four numeric inputs (size/opacity/hardness/step), logarithmic
size slider including the cached-raw-value path
(`Math.round(Math.pow(250, x))`), color input v-model, color input ref
forwarded to / cleared from store on mount/unmount.
- `ImageLayerSettingsPanel.test.ts` (17): mask opacity slider + canvas
style sync, blend-mode select with all 3 enum values + default fallback,
three layer-visibility checkboxes (with and without canvas refs),
activate-layer button forwarding to `toolManager.setActiveLayer`,
disabled-when-active and "show paint button only when Eraser" branches,
base image preview src binding.
- `SettingsPanelContainer.test.ts` (5): tool → component routing
(`MaskBucket` → bucket panel, `MaskColorFill` → color panel, anything
else → brush panel).

## Review Focus

- Stack matches sibling control tests (`@testing-library/vue` +
`userEvent` + stub child controls). Each panel's child `SliderControl` /
`ToggleControl` / `DropdownControl` is replaced by a minimal `<button>`
stub that emits `update:modelValue` on click, so panel logic is
decoupled from control internals (already covered by their own tests).
- Two panels (`Brush`, `ImageLayer`) need real reactivity
(`brushSettings.size` changes through a setter must propagate to the
slider's modelValue computed; `currentTool` / `activeLayer` mutations
between tests). They use `reactive()` from Vue at top level + a `let
mockStore` reset in `beforeEach`. Plain object mocks would either
silently no-op or leak state between tests.
- The two reactive panels enable file-level `eslint-disable
testing-library/no-container, testing-library/no-node-access` because
the unlabeled DOM (shape divs, unlabeled `<input type="number">`/`<input
type="color">`/`<input type="checkbox">`/`<select>`) genuinely can't be
queried via `screen.getByRole/Label`. Each disable has a comment
explaining why.
- `BrushSettingsPanel` has a non-trivial cached-value branch in
`brushSizeSliderValue` computed: when `rawSliderValue` and `brushSize`
are in sync, the getter returns the raw float instead of recomputing
`log(size)/log(250)`. Test stubs `setBrushSize` to actually write back
so the next read takes the cached path.
- `ImageLayerSettingsPanel` has a `display: 'block' | 'none'` style
binding. happy-dom doesn't populate `el.style.display` from inline style
strings reliably, so the test asserts via `el.getAttribute('style')`
instead.
- `SettingsPanelContainer` uses inline component stubs that render
unique text — assertion is just `textContent.toContain('brush-panel')`
etc. No need for component-instance probing.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature (button group, slider, etc.), `vi.hoisted` for mock
state where reactivity isn't required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11647-test-add-unit-tests-for-mask-editor-settings-panels-34e6d73d36508117911bc0850ce085e1)
by [Unito](https://www.unito.io)
2026-04-26 20:27:26 -04:00
Terry Jia
32b266f3e9 test: add unit tests for SidePanel, MaskEditorButton, and BrushCursor (#11648)
## Summary

Add unit tests for three small mask editor Vue components (`SidePanel`,
`MaskEditorButton`, `BrushCursor`), all reaching **100%** across
coverage dimensions.

## Changes

- **What**: Add 3 sibling test files (17 tests total):
- `SidePanel.test.ts` (3): renders both `SettingsPanelContainer` and
`ImageLayerSettingsPanel`; forwards `toolManager` prop through to
`ImageLayerSettingsPanel`; works with the prop omitted.
- `MaskEditorButton.test.ts` (3): renders with localized `aria-label`
when `isSingleImageNode` is true; hidden via `v-show` (style `display:
none`) when false; click executes `Comfy.MaskEditor.OpenMaskEditor`
command.
- `BrushCursor.test.ts` (11): opacity 1 / 0 from `brushVisible`; size =
2 × effective brush size × zoom (with hardness scaling); border-radius
50% / 0% for Arc / Rect; position from `cursorPoint + panOffset -
radius`, with optional `containerRef.getBoundingClientRect` offset;
gradient preview hidden / shown; flat fill at `effectiveHardness === 1`.

## Review Focus

- `SidePanel` test stubs both child panels to bare `<div data-testid>`
so the test verifies wiring (prop forwarding, child rendering) without
being affected by the children's i18n / store dependencies.
- `MaskEditorButton` mocks `useCommandStore.execute` and
`useSelectionState.isSingleImageNode` (as a Vue ref). Real i18n via
`createI18n` + `globalInjection: true` — the template uses `$t(...)`
which requires global injection in composition mode.
- For the v-show hidden case, `getByLabelText('...', { selector:
'button' })` works where `getByRole('button', { hidden: true })` doesn't
reliably resolve through the `<Button>` wrapper component.
- `BrushCursor` uses `reactive()` mock store and queries the brush /
gradient elements by id (`#maskEditor_brush`,
`#maskEditor_brushPreviewGradient`). File-level eslint-disable for
`testing-library/no-node-access` because the component's anchor elements
are styled divs without ARIA roles or labels.
- The `radial-gradient` (hardness < 1) branch of `gradientBackground` is
intentionally **not** asserted via rendered DOM: the computed returns a
multi-line template literal that happy-dom's CSS parser drops entirely.
The math (`getEffectiveBrushSize` / `getEffectiveHardness`) is covered
by `brushUtils.test.ts`. v8 reports 100% branch coverage because Vue
evaluates the computed regardless of whether the resulting style string
is parsed by the DOM.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for command-store / selection-state
mocks, real i18n via `createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11648-test-add-unit-tests-for-SidePanel-MaskEditorButton-and-BrushCursor-34e6d73d365081e38c4afee32ddf2b0b)
by [Unito](https://www.unito.io)
2026-04-26 20:08:03 -04:00
Terry Jia
2d4ca9c387 test: add unit tests for PointerZone and ToolPanel (#11649)
## Summary

Add unit tests for `PointerZone` and `ToolPanel` mask editor components,
raising each from 0% to **91.89% / 100%** respectively.

## Changes

- **What**: Add 2 sibling test files (24 tests total):
- `PointerZone.test.ts` (13): mount exposes root element to
`store.pointerZone`; pointer events (down/move/up) forward to
`toolManager.handlePointer*`; `pointerleave` hides brush + clears
cursor; `pointerenter` calls `toolManager.updateCursor`; touch events
(start/move/end) forward to `panZoom.handleTouch*`; wheel zooms then
updates cursor with wheel coords; `isPanning` watcher sets cursor to
"grabbing" then back via `updateCursor`; contextmenu's default is
prevented.
- `ToolPanel.test.ts` (11): one container rendered per `allTools`; icon
HTML for each tool; current tool highlighted with
`maskEditor_toolPanelContainerSelected` class while others are not;
clicking a container calls `toolManager.switchTool` with the matching
enum value; zoom indicator rounds `displayZoomRatio * 100`; dimensions
text reflects `image.width × image.height` (or empty when no image);
clicking the zoom indicator calls `store.resetZoom`.

## Review Focus

- `PointerZone` mocks `useToolManager` / `usePanAndZoom` to plain
function bags via factory helpers. The component is mostly forwarding,
so the test's value is in *event mapping* (which event triggers which
handler), not handler internals.
- happy-dom doesn't propagate `clientX` / `clientY` through the
`WheelEvent` constructor; the wheel test sets them via
`Object.defineProperty` after construction. Without this,
`updateCursorPosition` reads `undefined`.
- `PointerZone` ends at 91.89% statements / 70% branch — uncovered lines
are the `onMounted` early-return when `pointerZoneRef.value` is
`undefined` (always set in tests) and the watcher's same guard. Both
unreachable in normal use, intentionally not covered.
- `ToolPanel` mocks `iconsHtml` to `<svg data-testid="icon-${tool}" />`
markers, letting tests assert per-tool rendering via `getByTestId`. Real
i18n via `createI18n` for the reset-zoom tooltip text.
- `getToolContainers` queries by class because tool buttons are
unlabeled `<div>`s with click handlers (no role / aria); a file-level
`eslint-disable testing-library/no-node-access` covers this and the
dimensions-span placeholder query.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `reactive()` mock store + `let mockStore` reset in
`beforeEach`, real i18n where keys are user-visible.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11649-test-add-unit-tests-for-PointerZone-and-ToolPanel-34e6d73d3650817daf85c0d33d16899d)
by [Unito](https://www.unito.io)
2026-04-26 20:07:35 -04:00
Dante
177224452e test(assets): add E2E tests for media type filter (#10784)
## Summary

Add Playwright E2E tests for media type filter in assets sidebar.

## Changes

- Add `filterButton`, `filterCheckbox(label)`, `openFilterMenu()` to
`AssetsSidebarTab` fixture
- New `Assets sidebar - media type filter` describe block with 3 tests:
- Filter menu shows all 4 media type checkboxes (Image, Video, Audio,
3D)
  - Unchecking image filter hides image assets
  - Re-enabling filter restores hidden assets
- Mock jobs use distinct file extensions (.png, .mp4, .mp3) since
filtering is extension-based

## Review Focus

Tests tagged `@cloud` — filter button is gated behind `isCloud` in
`MediaAssetFilterBar.vue`.

Fixes #10780

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10784-test-assets-add-E2E-tests-for-media-type-filter-3356d73d3650810a8ecdd102e9f5b47e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:15:03 +09:00
Terry Jia
6bf75b4cf0 refactor(load3d): introduce ModelAdapter abstraction for the loader switch (#11627)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
the per-format loader logic needs to live behind a stable seam before
splat-specific fixes (orientation, async-decoder waits, GPU dispose,
custom bounds) and capability-driven UX gating can be added without
touching `LoaderManager`'s switch every time.

## Summary

Pure refactor. Extracts the per-extension switch inside `LoaderManager`
into three `ModelAdapter` implementations and wires the manager to
dispatch through them. **No behavior change** — same loader code paths,
same outputs, same fallbacks. Sixth in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**:
- `ModelAdapter.ts` (new): defines the `ModelAdapter` interface (`kind`,
`extensions`, `capabilities`, `load`), a `ModelLoadContext` that exposes
only the `SceneModelManager` surface adapters need (`setOriginalModel`,
`registerOriginalMaterial`, `standardMaterial`, `materialMode`), and a
shared `fetchModelData(path, filename)` helper.
- `MeshModelAdapter.ts` (new): owns `stl`, `fbx`, `obj`, `gltf`, `glb`.
Each branch is a 1:1 lift of the corresponding `case` from
`LoaderManager.loadModelInternal` on `main`.
- `PointCloudModelAdapter.ts` (new): owns `ply`. Includes the existing
`FastPLYLoader` / `PLYLoader` fallback and the `pointCloud` vs mesh
branching logic.
- `SplatModelAdapter.ts` (new): owns `spz`, `splat`, `ksplat`. Wraps the
`SplatMesh` in a `Group` exactly like the previous `loadSplat` did.
- `LoaderManager.ts`: now owns just an adapter array (default = the
three above) and a small dispatch path. `pickAdapter` matches by
extension and routes PLY → splat when the `Comfy.Load3D.PLYEngine`
setting is `sparkjs` (preserving the previous routing).
`getCurrentAdapter()` is the new public reader used by `Load3d`.
- `Load3d.isSplatModel` / `isPlyModel` now query
`loaderManager.getCurrentAdapter()?.kind` instead of doing
tree-introspection (`containsSplatMesh`) or `instanceof
THREE.BufferGeometry` checks. Same return values, decoupled from the
model shape.
- `LoaderManagerInterface` no longer exposes the per-format loader
fields (`gltfLoader`, `objLoader`, etc.); those are now
adapter-internal.
- `SceneModelManager` is **unchanged** in this PR. Its existing
`containsSplatMesh()` traversal and PLY material-mode rebuild stay put;
a follow-up PR refactors them once capability gating is in place.

## Review Focus

- **Loader equivalence**: the body of every `case` in `main`'s
`LoaderManager.loadModelInternal` is now in the corresponding adapter's
`load()` method. Easiest way to verify: diff `main`'s
`LoaderManager.loadModelInternal` against the four `load()` bodies and
confirm each branch's behavior (file fetch + parse + material wiring +
group wrapping) is byte-identical.
- **Dispatch parity**: `pickAdapter` produces the same routing as `main`
— extension match first, then the PLYEngine === 'sparkjs' override
hoisted up from inside the old `loadPLY`.
- **Capability fields are dormant**: the `ModelAdapterCapabilities`
record (`fitToViewer`, `materialModes`, `fitTargetSize`, …) is declared
on every adapter but **not consumed anywhere in this PR**.
SceneModelManager / Load3d / Load3DControls still read no capability
data. The follow-up PR turns these on.
- **`setOriginalModel` / `registerOriginalMaterial`**: adapters now go
through the `ModelLoadContext` getter rather than reaching into
`modelManager` directly. The context's `standardMaterial` and
`materialMode` are exposed via getters so a late-bound `materialMode` is
read at the actual call site, not snapshotted at context creation.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** |
| `MeshModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (new) | 97.22% | 61.11% | 75% | 97.22% |
| `SplatModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.17% | 86.66% | **100%**
|
| `Load3d.ts` (modified) | 6.63% | 0% | 13.68% | 6.7% |

All four new files are at or near 100% via dedicated unit tests for each
adapter (load happy path, error propagation, extension declarations,
capability shape). `LoaderManager.test.ts` exercises the dispatch logic
— extension matching, the `ply → splat` sparkjs override, the stale-load
discard, the load-context proxying — across 34 cases. The two changed
`Load3d.ts` methods (`isSplatModel`, `isPlyModel`) get dedicated tests
verifying they read the current adapter's `kind` and fall back to
`false` when none is loaded.

`Load3d.ts`'s overall 6.7% number is the pre-existing baseline — the
existing `Load3d.test.ts` covers façade methods via prototype injection
rather than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). PR-F's surface in
`Load3d.ts` is two method bodies, both covered by the new adapter-driven
kind queries test.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11627-refactor-load3d-introduce-ModelAdapter-abstraction-for-the-loader-switch-34d6d73d3650811b8a1ccc55b45100f2)
by [Unito](https://www.unito.io)
2026-04-26 18:32:51 -04:00
Christian Byrne
7a13340989 chore: add 301 redirects for old Framer case study URLs (#11654)
## Summary

Add 301 redirects for old Framer case study URLs to new `/customers/`
pages.

## Changes

- Add `redirects` config to `apps/website/astro.config.ts` mapping two
old Framer enterprise case study URLs to their new Astro customer pages

## Testing

### Automated

- Website build succeeds with redirect pages generated
- Lint, typecheck, and format checks pass

### E2E Verification Steps

1. Deploy to preview
2. Visit
`/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping`
— should 301 redirect to `/customers/moment-factory/`
3. Visit
`/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui`
— should 301 redirect to `/customers/series-entertainment/`

Fixes #11583

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11654-chore-add-301-redirects-for-old-Framer-case-study-URLs-34e6d73d36508187a386eed3e25cf1b2)
by [Unito](https://www.unito.io)
2026-04-26 15:13:32 -07:00
Terry Jia
1b07e82ff7 fix: resolve mesh widget thumbnails via asset preview API (#11538)
## Summary
The Load3d select-model widget was passing the raw .glb URL as the item
preview_url, which browsers can't render as an image, producing the
broken-image icon on cloud/local asset-enabled servers.

Resolve thumbnails lazily from the asset API using the preview_id link
(matching Media3DTop's behavior), look up by basename to stay consistent
with the write path in useLoad3d, and fall back to a 3D-box placeholder
when no preview exists yet.

## Screenshots
before
<img width="1112" height="1333" alt="image"
src="https://github.com/user-attachments/assets/a8fa88ad-ab82-4951-be03-d28111322e30"
/>

after
with asset-enable on BE

https://github.com/user-attachments/assets/34b416af-5729-4ad0-bf17-722461ffc659

without asset-enable on BE
<img width="1026" height="1201" alt="image"
src="https://github.com/user-attachments/assets/71fd463f-ca77-4d63-85ed-01261d032d53"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11538-fix-resolve-mesh-widget-thumbnails-via-asset-preview-API-34a6d73d365081d2aefac044dab0dfc3)
by [Unito](https://www.unito.io)
2026-04-26 18:08:30 -04:00
Terry Jia
9f4c54eb24 refactor: extract Load3d right-click guard to load3dContextMenuGuard (#11625)
## Summary

Pull the right-click vs right-drag detection out of `Load3d` into a
sibling helper. Mechanical refactor — no behavior change. Third of four
small PRs splitting up the [`remove-ply-3dgs-nodes-squashed`
mega-commit.](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.)

## Changes

- **What**: New `load3dContextMenuGuard.ts` exports
`attachContextMenuGuard(target, onMenu, { isDisabled, dragThreshold })`.
It installs `mousedown` / `mousemove` / `contextmenu` listeners against
a single `AbortController` and returns one dispose function.
- `Load3d` now calls `attachContextMenuGuard(this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event), { isDisabled: () =>
this.isViewerMode })` and stores the returned disposer in a single
field. Drops four private fields (`rightMouseStart`, `rightMouseMoved`,
`dragThreshold`, `contextMenuAbortController`) plus the now-redundant
`showNodeContextMenu` private method.
- The 5px drag threshold and `isViewerMode` gating are preserved.

## Review Focus

- The three event handlers (`mousedown`, `mousemove`, `contextmenu`)
inside the new helper match the old inline implementations one-for-one —
same `e.button === 2` / `e.buttons === 2` checks, same call to
`exceedsClickThreshold`, same `preventDefault` + `stopPropagation`
ordering.
- `isDisabled: () => this.isViewerMode` replaces the inline `if
(this.isViewerMode) return` early-out — same gate, just lifted to a
callback.
- A single `AbortController.abort()` (in the returned disposer) replaces
the old four-field teardown in `Load3d.remove()`.
- 9 unit tests cover the helper: click vs drag distinction at the
threshold, drag-then-click reset, `isDisabled` short-circuit, and the
disposer detaching all three listeners.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `load3dContextMenuGuard.ts` (new) | **100%** | **93.33%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | 7.12% | 0% | 13.97% | 7.18% |

The single uncovered branch on `load3dContextMenuGuard.ts` (line 22) is
the default-parameter fallback for `dragThreshold` when the caller omits
it — `Load3d` always passes `{ isDisabled, dragThreshold: 5 }` through
`attachContextMenuGuard`'s second-arg destructure, so the default never
fires under the production call path. Adding a test that omits
`dragThreshold` would push it to 100%; left as-is to avoid a
change-detector test for a default value.

The `Load3d.ts` numbers are the pre-existing baseline on `main` —
`Load3d.test.ts` covers façade methods via prototype injection rather
than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). The
`initContextMenu` rewrite and the `remove()` teardown change both sit in
those same uninstantiated paths and rely on browser e2e for end-to-end
coverage, the same as before. Net: the click-vs-drag logic that
previously had no unit test is now ≥93% covered through the extracted
helper.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11625-refactor-extract-Load3d-right-click-guard-to-load3dContextMenuGuard-34d6d73d36508162aecef46553a3f50d)
by [Unito](https://www.unito.io)
2026-04-26 17:55:09 -04:00
Terry Jia
6f6fc88b0f test: add unit tests for MaskEditorContent main container (#11651)
## Summary

Add unit tests for `MaskEditorContent` (the mask editor's main
orchestration container), raising coverage from 0% to **94.11% / 83.72%
/ 83.33% / 94.11%** (statements / branches / functions / lines).

## Changes

- **What**: Add `src/components/maskeditor/MaskEditorContent.test.ts`
(12 tests) covering:
- **Mount**: keyboard listeners attached, ResizeObserver observes the
container, all 5 canvas refs assigned to the store before init runs.
- **Init flow**: `loader.loadFromNode` → `imageLoader.loadImages` →
`panZoom.initializeCanvasPanZoom` → `canvasHistory.saveInitialState` →
`brushDrawing.initGPUResources` → `initPreviewCanvas` chain runs in
order; child UI (`ToolPanel` / `PointerZone` / `SidePanel` /
`BrushCursor`) only renders after init succeeds; GPU preview canvas
resolution matches the mask canvas.
- **Init errors**: rejection from `loader.loadFromNode` or
`panZoom.initializeCanvasPanZoom` is caught, logged, and triggers
`dialogStore.closeDialog()`.
- **ResizeObserver**: callback invokes `panZoom.invalidatePanZoom()`
(captured the constructor argument to call it manually).
  - **Drag**: `Ctrl+drag` is preventDefault'd; plain drag is not.
- **Unmount**: cleanup runs `brushDrawing.saveBrushSettings`,
`keyboard.removeListeners`, `canvasHistory.clearStates`,
`store.resetState`, `dataStore.reset`.

## Review Focus

- Heavy mock surface (10 modules): the 3 stores, 5 composables, plus 4
child Vue components and `LoadingOverlay`. All mocks are `vi.hoisted`
module-level. `mockStore` is `reactive()` because the source mutates
`activeLayer` (visible in template binding), `maskCanvas`, etc.; the
rest are plain function bags.
- Child components are stubbed to bare `<div data-testid>` so init
reveal can be asserted via `screen.findByTestId(...)` without engaging
their real implementations (each has its own test file).
- `MockResizeObserver` captures the constructor callback in module-level
`lastResizeCallback`. The "invalidate on resize" test invokes it
manually with empty args — that's enough to exercise the source's `if
(panZoom) { await panZoom.invalidatePanZoom() }` branch since the
callback only consumes `panZoom` from closure.
- happy-dom doesn't propagate `ctrlKey` through the `DragEvent`
constructor, so the drag tests set it via `Object.defineProperty(event,
'ctrlKey', { value })` (same pattern used in `PointerZone.test.ts` for
wheel `clientX/Y`).
- 94.11% line coverage — the two uncovered blocks (`containerRef`
missing, canvas refs missing) are early-return error paths unreachable
when Vue successfully mounts; not worth constructing a fixture to
trigger.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` mocks reset via `beforeEach`,
`screen.findByTestId` for async render assertions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11651-test-add-unit-tests-for-MaskEditorContent-main-container-34e6d73d365081b38af2e057cb7daf9e)
by [Unito](https://www.unito.io)
2026-04-26 17:52:29 -04:00
Terry Jia
492bec28c8 test: add unit tests for TopBarHeader (#11650)
## Summary

Add unit tests for `TopBarHeader` mask editor dialog component, raising
coverage from 0% to **100%** across statements, branches, functions, and
lines.

## Changes

- **What**: Add `src/components/maskeditor/dialog/TopBarHeader.test.ts`
(17 tests) covering:
  - Localized title rendering.
  - Undo / Redo buttons forward to `store.canvasHistory.{undo,redo}`.
- Four transform buttons (rotate left / right, mirror horizontal /
vertical) call the matching `canvasTransform` action — parametrized via
`it.each`.
- All four transform error paths: rejected promise is caught, swallowed,
and logged with the right `[TopBarHeader] ... failed:` prefix.
- Invert calls `canvasTools.invertMask`; Clear calls both
`canvasTools.clearMask` and `store.triggerClear`.
- Save: hides brush, awaits `saver.save()`, closes the dialog on
success; switches button text to "Saving" while in-flight; restores
brush + button label and logs on save failure.
  - Cancel: closes the dialog with the `global-mask-editor` key.

## Review Focus

- All five composable / store dependencies are mocked at module level
via `vi.hoisted`: `useMaskEditorStore`, `useDialogStore`,
`useCanvasTools`, `useCanvasTransform`, `useMaskEditorSaver`. Only the
store needs `reactive()` (`brushVisible` flips during save flow); the
rest are plain function bags.
- `Button.vue` is stubbed to a thin `<button :disabled>` so role queries
(`getByRole('button', { name: ... })`) resolve cleanly without dragging
in the real UI button's classes / variants.
- Real i18n via `createI18n`. Icon buttons rely on `:title` for their
accessible name; text buttons rely on slot text — both are reachable
through `getByRole('button', { name: ... })`.
- The "Saving" text test uses an unresolved promise to keep the
in-flight state observable; `waitFor` + `void user.click(...)` lets us
assert without awaiting the click. The dangling promise resolves at the
end so vitest doesn't complain.
- `it.each` parametrizes the four transform success paths and
(separately) the four error paths, keeping the file tight.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for cross-test mocks, real i18n with
`createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11650-test-add-unit-tests-for-TopBarHeader-34e6d73d365081adab66e460cf56accb)
by [Unito](https://www.unito.io)
2026-04-26 17:51:44 -04:00
Comfy Org PR Bot
13b660a15b 1.44.10 (#11620)
Patch version increment to 1.44.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>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-26 05:36:11 +00:00
Terry Jia
b232831441 fix: stop duplicate node creation when dropping image on Vue nodes (#11541)
## Summary
handleDrop checked `handled === true` to gate stopPropagation, but
onDragDrop from useNodeDragAndDrop is async and always returns a
Promise, so the check never matched. The drop then bubbled to the
document handler in app.ts and spawned a new LoadImage node in addition
to the one that accepted the drop.

Updated onDragDrop to take an optional claimEvent flag — when true, it
calls preventDefault()/stopPropagation() synchronously inside the
handler, only after the sync acceptance check passes (valid files /
same-origin URI), and before any await.
The Vue node now just calls await node.onDragDrop(event, true).
Rejected payloads (cross-origin URI, files filtered out, no valid files)
skip the claim and bubble to the document fallback as before. The
remaining edge case is async URI fetch failures, which we can't
sync-detect without speculatively claiming.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/d79a5101-370b-4873-8365-5f9ce188731b



after


https://github.com/user-attachments/assets/8b787474-eab9-4060-8146-c4d8bb24ff9f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11541-fix-stop-duplicate-node-creation-when-dropping-image-on-Vue-nodes-34a6d73d36508113b153e31768602933)
by [Unito](https://www.unito.io)
2026-04-25 20:51:39 -04:00
Dante
996e362ba6 test: add unit tests for form-dropdown internals (#11441)
## Summary

Adds 32 unit tests across 3 files covering the internals of the
FormDropdown family (input, filter, menu item). Part of a
widget-test-coverage sequence.

## Changes

- **What**:
- `FormDropdownInput.test.ts` (14) — placeholder vs selected-items
display, label preference, multi-item join, select-click emit,
file-input rendering/accept/multiple/disabled/upload event.
- `FormDropdownMenuFilter.test.ts` (8) — option rendering, v-model
update on click, single-option disabled state, import-button gating by
\`useModelUpload.isUploadButtonEnabled\` (mocked), \`showUploadDialog\`
invocation.
- `FormDropdownMenuItem.test.ts` (10) — label vs name preference,
img/video rendering by injected \`AssetKindKey\`, placeholder gradient,
list-small layout, click emits index, mediaLoad event, selection
indicator.

## Review Focus

- \`useModelUpload\` mocked at the module boundary with a dynamic import
of \`vue\` inside \`vi.mock\` (needed because \`vi.hoisted\` runs before
imports).
- \`AssetKindKey\` provided via \`global.provide\` using the
\`ComputedRef<AssetKind>\` shape.
- \`v-tooltip\` registered as a no-op directive to avoid render errors
in happy-dom.
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11441-test-add-unit-tests-for-form-dropdown-internals-3486d73d3650813cb4a1c6568280ef1a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 00:36:25 +00:00
Terry Jia
b0fa179b87 refactor: extract Load3d render loop to load3dRenderLoop (#11623)
## Summary

Pull the `requestAnimationFrame` loop and its activity-gated tick body
out of `Load3d` into a small `startRenderLoop({ tick, isActive })`
helper. Pure mechanical refactor — no behavior change. First of four
small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dRenderLoop.ts` exports `startRenderLoop` (returns
a `{ stop }` handle). `Load3d.startAnimation()` now constructs a loop
through it; `Load3d.remove()` calls `stop()` instead of
`cancelAnimationFrame`. Field `animationFrameId: number | null` becomes
`renderLoop: RenderLoopHandle | null`.

## Review Focus

- The tick body inside `startAnimation()` is byte-identical to the
previous inline body — only the rAF scheduling has moved.
- `isActive()` is now invoked through a `() => this.isActive()` closure
instead of a direct call inside the inline `animate` function, so the
activity check still fires once per frame and reads the same fields.
- The new helper has 4 unit tests covering: ticks while active, skip
while inactive, stop halts ticks, stop is idempotent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11623-refactor-extract-Load3d-render-loop-to-load3dRenderLoop-34d6d73d3650815c9c4ec7713e912e37)
by [Unito](https://www.unito.io)
2026-04-25 20:03:01 -04:00
Terry Jia
ba6dd2a09c refactor(load3d): extract viewport math to load3dViewport (#11624)
## Summary

Pull two pure helpers out of `Load3d` into a sibling module. Mechanical
refactor — no behavior change. Second of four small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dViewport.ts` exports two pure functions:
- `computeLetterboxedViewport({ width, height }, targetAspectRatio)`
returns `{ offsetX, offsetY, width, height }` — the aspect-ratio fitting
math that was duplicated inline in three places (`renderMainScene`,
`setBackgroundImage`, `handleResize`).
- `isLoad3dActive(flags)` consumes the activity-flag struct used by the
rAF tick gate; `Load3d.isActive()` now delegates to it.

## Review Focus

- The three call sites in `Load3d.ts` produce byte-identical viewport
rectangles for any `(containerWidth, containerHeight, targetAspect)`
triple — same branch on aspect-ratio comparison, same offset derivation.
Simplest way to verify: diff the math.
- `isLoad3dActive` ORs the same six flags as before in the same order.
Old implementation read `this.STATUS_MOUSE_ON_NODE` etc. directly; new
one reads them through a flags object built at the call site.
- 13 unit tests cover the helpers: the "wider" / "taller" / "matching"
aspect cases for letterboxing, and each individual flag flipping
`isLoad3dActive` on by itself.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11624-refactor-load3d-extract-viewport-math-to-load3dViewport-34d6d73d365081ab9af5fc445bf5bd5a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 17:48:44 -04:00
Kelly Yang
4a9001f675 test: add E2E tests for image crop widget Levels 4-7 (#11571)
## Summary

Adds 12 Playwright E2E tests for the `ImageCropV2` widget covering
aspect ratio selection, lock/unlock behavior, constrained resize, and
BoundingBox numeric input — all of which had zero test coverage.

## Changes

**Level 4 — Aspect Ratio Selection** (`with source image after
execution`)
- Selecting 16:9 preset adjusts crop height proportionally via
`applyLockedRatio`
- Selecting Custom unlocks the ratio and restores all 8 resize handles

**Level 5 — Lock/Unlock** (`without source image` + `with source image
after execution`)
- Selecting a preset auto-enables the lock (aria-label changes to
"Unlock aspect ratio")
- Unlocking after a preset reverts the dropdown display to "Custom"
- Full lock→unlock round-trip verifies handle count (4 → 8) and
aria-label on both transitions

**Level 6 — Constrained Resize** (`with source image after execution`)
- NW corner drag grows origin (x, y decrease) and dimensions while
maintaining ratio
- SE corner drag beyond image edge clamps to boundary
- NW corner drag beyond (0, 0) clamps x/y to image boundary
- Inward SE corner drag enforces `MIN_CROP_SIZE` (16px minimum)

**Level 7 — BoundingBox Numeric Input** (`with source image after
execution`)
- X increment button increments crop x by 1
- Width increment button increments crop width by 1
- BoundingBox inputs reflect updated position after a drag

No source code was modified.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus adding
`data-testid` attributes to BoundingBox inputs, with no behavioral logic
changes expected.
> 
> **Overview**
> **Expands E2E coverage for the `ImageCropV2` widget** by adding new
Playwright tests for ratio preset selection, lock/unlock behavior,
constrained resizing (including boundary clamping and min size), and
BoundingBox numeric input updates.
> 
> **Improves testability of BoundingBox controls** by adding
`data-testid` attributes to the `WidgetBoundingBox.vue`
`ScrubableNumberInput` fields (`x`, `y`, `width`, `height`) so E2E tests
can target increment/input elements reliably.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b008f42942. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11571-test-add-E2E-tests-for-image-crop-widget-Levels-4-7-34b6d73d365081c79118ca9ae08f291c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 00:17:12 -04:00
Dante
9cf035879f test: add WidgetCurve unit tests (#11469)
## Summary

Splits the WidgetCurve test coverage out of #11446 so this widget can be
reviewed independently.

## Changes

- **What**: Adds WidgetCurve unit tests covering point forwarding,
interpolation updates, disabled-state behavior, and upstream value
handling.

## Review Focus

Focused test-only PR extracted from #11446.
Validated with `pnpm test:unit -- --run
src/components/curve/WidgetCurve.test.ts`.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11469-test-add-WidgetCurve-unit-tests-3486d73d365081c2a68bc8403fa0265f)
by [Unito](https://www.unito.io)
2026-04-24 17:47:54 -07:00
Alexander Brown
d0e9984a73 feat: update BYOKeySection images to enterprise node WebPs (#11614)
Update BYOKeySection card images from placeholder API logos to dedicated
enterprise node WebP images hosted on media.comfy.org.

## Changes
- Replace `logo-purple.webp` and `logo-yellow.webp` with
`enterprise_node_1.webp` and `enterprise_node_2.webp`
- New images are near-lossless WebP (~70% smaller than source PNGs)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11614-feat-update-BYOKeySection-images-to-enterprise-node-WebPs-34c6d73d365081239d92c649eb563b7e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 20:03:21 +00:00
pythongosssss
125c11b3d0 fix: translate blueprint label (#11573)
## Summary

Fixes hardcoded "Blueprint" text and adds e2e coverage to test
visibility, resolving #11473

## Changes

- **What**: 
- add translation
- add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11573-fix-translate-blueprint-label-34b6d73d365081009215e06be6aa1fa0)
by [Unito](https://www.unito.io)
2026-04-24 19:27:47 +00:00
Alexander Brown
725ed120e8 fix: disable parallax on mobile to prevent enterprise section overlap (#11609)
## Summary

Disable parallax on mobile in the enterprise DataOwnershipSection to
prevent images from overlapping the next section.

## Changes

- **What**: Add `mediaQuery` option to `useParallax` composable, using
GSAP's `matchMedia()` to create/revert animations responsively.
DataOwnershipSection now only applies parallax at the `lg` (1024px+)
breakpoint.

## Review Focus

GSAP `matchMedia` automatically reverts animations (resetting
transforms) when the query stops matching, so no manual cleanup is
needed on resize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11609-fix-disable-parallax-on-mobile-to-prevent-enterprise-section-overlap-34c6d73d365081a48d55e4cf880e3bab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:13:00 -07:00
Alexander Brown
453a0edd1e chore: refresh Ashby careers snapshot (#11611)
## Changes

Refreshes the Ashby careers snapshot (`ashby-roles.snapshot.json`).

The "Business" department has been renamed to "Operations" on Ashby's
side. The same 3 roles (Senior Technical Recruiter, BizOps Strategist,
Founding Customer Success Manager) now appear under "Operations".

## Testing

- Snapshot was generated via `pnpm --filter @comfyorg/website
ashby:refresh-snapshot` which validates the API response through Zod
schemas before writing.
- Lint-staged checks (typecheck, eslint, oxlint, oxfmt) passed on
commit.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11611-chore-refresh-Ashby-careers-snapshot-34c6d73d3650813ea289c3c0371f882b)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:11:13 -07:00
Christian Byrne
5a8ded7959 Website: pull careers page listing from ashby API (#11590)
Careers

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-24 11:51:43 -07:00
Alexander Brown
bb23b9352c fix: update enterprise hero SVG to match updated design (#11608)
## Changes

Update the enterprise hero SVG in `HeroSection.vue` to match the updated
design export.

### Key changes

- **viewBox**: `600 -50 1000 1100` → `0 0 1600 1046` with `clip-path`
wrapper and background rects for proper clipping
- **Block pieces**: stroke/stroke-width moved from parent `<g>` to each
individual `<path>`, with duplicated paths for layered rendering
- **Block cluster**: wrapped in `.block-cluster` group with its own CSS
transform-origin
- **Ripple delay classes**: renamed `ripple-delay-*` → `delay-*`
- **Fade overlay**: added `pointer-events: none` to prevent blocking
interactions

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 10:35:24 -07:00
Alexander Brown
25f0b41f63 Remember what was forgotten (#11603)
## Summary

Every page has a story to tell.

## Changes

- **What**: Something was missing. Now it isn't.

## Review Focus

Look closely at what was lost.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11603-Remember-what-was-forgotten-34c6d73d36508184b3cef39d0be4a3bd)
by [Unito](https://www.unito.io)
2026-04-24 10:28:32 -07:00
Alexander Brown
e7673fcca7 update: API page links to keys and docs (#11606)
## Summary

Update API page CTA buttons to link directly to the API keys page and
API docs instead of generic platform/cloud/docs links.

## Changes

- **What**: Point "Get API Keys" buttons in HeroSection and StepsSection
to `platform.comfy.org/profile/api-keys`; point "Read the Docs" button
to `docsApi` route; add `apiKeys` entry to `externalLinks` config.

## Review Focus

Straightforward link target updates — verify the new URLs are correct.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11606-update-API-page-links-to-keys-and-docs-34c6d73d365081268bb5dd55c33646c8)
by [Unito](https://www.unito.io)
2026-04-24 10:28:03 -07:00
Yourz
7038cab926 update: replace placeholder images in API automation section (#11602)
## Changes

- Replace placeholder images in API automation section with final assets
  - Feature 1: `desert.webp` → `precision-tools.webp`
  - Feature 3: `free.webp` → `infrastructure-nodes.webp`


<img width="1000" height="552" alt="Kapture 2026-04-25 at 00 54 23"
src="https://github.com/user-attachments/assets/dd503d2f-56c3-4346-adfa-27b3b92a04a8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11602-update-replace-placeholder-images-in-API-automation-section-34c6d73d365081319ca4db9b60099835)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 16:02:36 +00:00
Yourz
1f888de0f6 update: content in Price page (#11600)
## Summary

<!-- One sentence describing what changed and why. -->

Update wrong content in Price page of new website

## Changes

- **What**: <!-- Core functionality added/modified -->
- update both English and Chinese content of translate key
`pricing.included.feature2.description`

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
<img width="1046" height="147" alt="image"
src="https://github.com/user-attachments/assets/0f2dc66c-d384-4fb4-85cb-2d9f68469dbc"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11600-update-content-in-Price-page-34c6d73d365081bb9ae1e09842c77249)
by [Unito](https://www.unito.io)
2026-04-24 15:59:18 +00:00
Yourz
b7a8056ab0 update: local hero illustration stroke colors and fix overflow clipping (#11601)
## Changes

- Update SVG stroke color from `#7E7C78` to `#4D3762` to match design
spec
- Reduce hex node stroke width from 6 to 3
- Fix illustration bottom edge being hard-clipped by removing
`lg:overflow-y-clip` from section
- Adjust container aspect ratio to match SVG viewBox proportions


<img width="1000" height="1287" alt="Kapture 2026-04-25 at 00 41 16"
src="https://github.com/user-attachments/assets/a3a14fba-74b3-4051-ac12-c175c4b3bd61"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11601-update-local-hero-illustration-stroke-colors-and-fix-overflow-clipping-34c6d73d3650811e9f0bdc1a3274800e)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 15:57:11 +00:00
pythongosssss
40fec7882c chore: remove unused glslUtils helpers (#11597)
## Summary

These look to have been added as part of the initial phased
implementation to align with the backend code, however they are unused:
- `detectOutputCount` - The frontend only shows a single output preview
which imo is fine, we can add multi display in future if required
- `hasVersionDirective` - The backend needs this as it can execute other
version shaders, the web browser will automatically validate and throw
an error if it is not a valid shader, this gets surfaced to the user
already.

## Changes

- **What**:  
- remove unused functions & tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11597-chore-remove-unused-glslUtils-helpers-34c6d73d365081eab784d205b8a0053e)
by [Unito](https://www.unito.io)
2026-04-24 10:52:45 +00:00
jaeone94
17d980dbc8 feat: add inline-CTA nightly survey for error panel (#11591)
## Summary

Adds an inline-CTA Typeform survey for the redesigned error panel,
targeting nightly localhost users. Reuses the existing node-search
survey infrastructure rather than introducing a parallel stack.

## Changes

- **What**:
- `surveyRegistry` gains optional `presentation: 'floating' |
'inline-cta'` and a `getFloatingSurveys()` helper; controller filters by
it.
- `NightlySurveyPopover` accepts `mode` + `v-model:open`. Manual mode
skips the eligibility watcher, drops the "Not Now" button, and leaves
open/close/markSeen to the parent.
- New `ErrorPanelSurveyCta.vue` renders a CTA in the error tab footer
once `useExecutionErrorStore.hasAnyError` has transitioned `null →
value` at least 3 times.
- Popover lives in `NightlySurveyController` (session-persistent).
Shared state via module-level singleton (`useErrorSurveyPopoverState`)
so the iframe survives error-tab unmounts during workflow switches.
- `useTypeformEmbed` centralises script loading (singleton Promise, 10s
timeout, explicit `window.tf.load()` on each new container). Necessary
because the embed's DOMContentLoaded auto-scan only fires once; late
consumers need an explicit re-scan to render.
  - CTA and feedback-gate strings added under `errorPanelSurvey.*`.

## Review Focus

- Manual-mode flow in `NightlySurveyPopover.vue` — CTA click is routed
through the module singleton; popover stays mounted after the first open
(`hasOpenedOnce` + `v-show`) to preserve the iframe across repeated
open/close cycles and workflow switches.
- `useTypeformEmbed.ts` — the `window.tf.load()` trick (verified against
the CDN `embed.js`) is what lets two surveys coexist; without it
Typeform's one-shot DOM scan misses whichever element mounts second.
- `NightlySurveyController.vue` guards against double-mount by requiring
`presentation === 'inline-cta'` on `errorPanelConfig`.
- Scope is nightly+localhost only (`isNightlyLocalhost` in
`useSurveyEligibility`); async component gate in `TabErrors.vue` keeps
this out of Cloud/Desktop/stable bundles.

## Test plan

- [x] `IS_NIGHTLY=true pnpm dev`, clear `Comfy.SurveyState` +
`Comfy.FeatureUsage`, trigger 3 failed runs → CTA appears in error tab
footer.
- [x] Click "Give feedback" → Typeform popover opens and renders the
form.
- [x] Close popover, switch workflow (error tab unmounts), trigger a new
error → CTA reappears and reopening the popover shows the same iframe.
- [x] Open error-panel popover, close, then trigger 3 node searches →
node-search auto-popup renders its own iframe correctly (two surveys
coexist).
- [x] CTA × dismisses and persists (`seenSurveys['error-panel']`).
- [x] "Don't ask again" inside popover sets `optedOut: true` and hides
all nightly surveys.
- [x] Cloud/Desktop/stable builds: CTA never renders, controller's
manual popover doesn't mount.

## Screenshot


https://github.com/user-attachments/assets/91145f23-fd1e-4caf-b6cc-4b97d33ed6b7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11591-feat-add-inline-CTA-nightly-survey-for-error-panel-34c6d73d3650817d9f95fddcf64633de)
by [Unito](https://www.unito.io)
2026-04-24 08:14:08 +00:00
Alexander Brown
9cd36c7f7d fix: website polish — prefetch, Safari video controls, border-spin perf (#11586)
## Summary

Website polish: enable Astro prefetch, fix mobile Safari video controls,
and optimize the ProductShowcase border-spin animation.

## Changes

- **What**:
- Enable `prefetch: { prefetchAll: true }` in Astro config for faster
navigation
- Hide native iOS Safari play-button overlays on autoplay background
videos (`BlobMedia`, global CSS)
- Refactor `ProductShowcaseSection` border-spin animation: use
`@property --border-angle` with `conic-gradient` directly on the
container (removes extra spinning `div`), gate animation on
`useIntersectionObserver` visibility, lazy-load non-active videos with
`preload="none"`, add `will-change-[opacity]` for smoother crossfade

## Review Focus

- `@property` CSS registered custom property — verify browser support is
acceptable for the website target audience
- `!important` on the webkit media controls rule — necessary to override
UA styles on iOS Safari

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11586-fix-website-polish-prefetch-Safari-video-controls-border-spin-perf-34c6d73d36508108a0e8f67df9b32a88)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 07:44:02 +00:00
Alexander Brown
8e3041aa2f ci: add e2e-status gate job for required checks (#11587)
## Summary

Follow-up to #11568. Fixes Playwright required checks hanging as
"Waiting for status to be reported" on PRs with no e2e-relevant file
changes.

## Problem

PR #11568 added a `changes` filter to skip E2E when only
docs/apps/storybook files are touched. The E2E workflow skips correctly,
but branch rulesets require the 11 matrix-expanded check names (e.g.
`playwright-tests-chromium-sharded (1, 8)`). When a matrix job is
skipped via dependency, GitHub only reports the parent job name — the
individual matrix entries are never reported, so required checks hang
forever.

## Fix

Add a single `e2e-status` gate job that:
- Uses `if: always()` so it always runs regardless of skipped
dependencies
- Passes when E2E was intentionally skipped (no relevant changes)
- Passes when all matrix jobs succeeded
- Fails when any matrix job failed

**After merging**, the ProtectMain and Core release branches rulesets
should be updated to require `e2e-status` instead of the 11 individual
matrix check names.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11587-ci-add-e2e-status-gate-job-for-required-checks-34c6d73d365081b59c01e8d61d0b808d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 05:43:45 +00:00
Alexander Brown
0b2e605dee content: website copy and pricing updates (#11567)
## Summary

Update website copy: fix branding ("Comfy" → "ComfyUI"), correct pricing
runtime, remove "coming soon" seat features, and shorten use-case label.

## Changes

- **What**: Copy corrections in `translations.ts` (branding, runtime "60
min" → "30 min" for Standard plan, remove placeholder seat features for
Creator/Pro plans); trim feature arrays in `PriceSection.vue`

## Review Focus

Verify zh-CN translations still make sense after the English copy
changes (runtime string not updated in zh-CN).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11567-content-website-copy-and-pricing-updates-34b6d73d365081c29af8ee1469b08358)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-24 04:25:15 +00:00
Christian Byrne
d038193d95 fix: add assetsPrefix to avoid /_astro collision with workflows site (#11584)
## Problem

The website and workflows sites both emit build assets under `/_astro/`.
The comfy-router sends all `/_astro/*` requests to the workflows Vercel
project, so when a page from the website references
`/_astro/SiteNav.xxx.js`, it 404s because that file only exists on the
website's Vercel deployment.

## Fix

Add `build.assetsPrefix: '/_website'` to the Astro config. This makes
Astro emit asset references as `/_website/SiteNav.xxx.js` instead of
`/_astro/SiteNav.xxx.js`.

The comfy-router already routes `/_website/*` to the website origin, so
these assets will resolve correctly.

## Why this matters

**This is blocking the comfy.org cutover from Framer → new site.**
Without it, the homepage loads but all CSS/JS 404s and nothing renders.

## Change

One line in `apps/website/astro.config.ts`:
```ts
build: {
  assetsPrefix: '/_website'
}
```

## Verification

After merge + Vercel deploy:
- Homepage HTML should reference `/_website/*.js` and `/_website/*.css`
instead of `/_astro/*`
- `/workflows` page should still reference `/_astro/*` (unaffected)
- Both sites render correctly through the proxy

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11584-fix-add-assetsPrefix-to-avoid-_astro-collision-with-workflows-site-34c6d73d36508101a932caa2905f0a2b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 21:05:21 -07:00
Benjamin Lu
04e430e006 fix: dedupe keybinding modifier display (#11570)
## Summary

Fix keybinding display so pressing a modifier key by itself shows that
modifier once instead of duplicated text like `Shift + Shift`.

## Changes

- **What**: Centralizes modifier key labels in `KeyComboImpl` and omits
the duplicated primary key when the pressed key is itself a modifier.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The keybinding model still includes active modifiers for normal
shortcuts like `Ctrl + Shift + k`, while modifier-only input now renders
as a single key. Regression coverage includes single modifier presses,
combined held modifiers, and a normal non-modifier shortcut.

Checks run: `pnpm exec vitest run
src/platform/keybindings/keyCombo.test.ts`; `pnpm lint:unstaged`; `pnpm
exec oxfmt --check src/platform/keybindings/keyCombo.ts
src/platform/keybindings/keyCombo.test.ts`; `pnpm exec vitest run
src/platform/keybindings/keyCombo.test.ts
src/platform/keybindings/keybindingStore.test.ts
src/platform/keybindings/keybindingService.escape.test.ts
src/platform/keybindings/keybindingService.canvas.test.ts`; `pnpm
typecheck`; pre-commit lint-staged checks; pre-push `pnpm knip --cache`.

Linear: FE-240

## Screenshots (if applicable)

Not applicable; covered by keybinding sequence unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11570-fix-dedupe-keybinding-modifier-display-34b6d73d365081968a88da4465c151de)
by [Unito](https://www.unito.io)
2026-04-24 03:41:42 +00:00
Comfy Org PR Bot
bab122ad6b 1.44.9 (#11581)
Patch version increment to 1.44.9

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11581-1-44-9-34c6d73d3650811a82d9fe7a039a7edc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-24 03:24:41 +00:00
Kelly Yang
bba3c38ae1 test: E2E coverage for painter widget (Levels 1–5) (#11551)
## Summary

Adds 7 new E2E tests for the Painter widget covering the remaining
untested items from Levels 1–5 of the widget test plan. All new tests
pass typecheck, lint, and the full pre-commit hook suite.

## Changes

**`browser_tests/tests/painter.spec.ts`**

- **Level 1.2** — assert node size ≥ 450×550 via the graph API to verify
the painter extension enforces its minimum dimensions
- **Level 1.3** — assert `width`, `height`, and `bg_color` widgets have
`options.hidden = true` so they don't appear as standard widget controls
- **Level 2.4** — cursor element becomes visible on pointer enter, its
CSS transform updates as the mouse moves across the canvas, and it hides
on pointer leave; uses `expect.poll` on transform to avoid the Vue
microtask race
- **Level 4.2** — set brush color to `#ff0000` via input event, draw a
stroke, sample a 40×40 region at canvas center and assert red pixels (R
> 200, G < 50, B < 50)
- **Level 4.3** — set opacity to 50%, draw a stroke, sample pixel alpha
values and assert semi-transparency (50 < α < 230)
- **Level 4.4** — focus the hardness slider, press ArrowLeft ×10, assert
the display value changes from `100%` to `90%`
- **Level 5.4** — set background color input to `#ff0000` via input
event, assert the canvas container div has `background-color: rgb(255,
0, 0)`

**`src/components/painter/WidgetPainter.vue`**

- Added `data-testid="painter-canvas-container"` to the inner canvas
wrapper div
- Added `data-testid="painter-cursor"` to the brush cursor div
- Added `data-testid="painter-bg-color-row"` to the background color
control row
- Added `data-testid="painter-hardness-value"` to the hardness display
span (mirrors the existing `painter-size-value` pattern)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus a few
`data-testid` attributes to stabilize selectors, with no functional
logic changes to the painter behavior.
> 
> **Overview**
> Adds **7 new Playwright E2E tests** for the Painter widget, covering
minimum node sizing/hidden standard widgets, cursor
visibility/positioning behavior, brush hardness display updates,
color/opacity effects on rendered strokes, and background color
application.
> 
> Updates `WidgetPainter.vue` to add a handful of **`data-testid`
hooks** (canvas container, cursor, background color row, hardness value)
used by the new tests.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a6da0c3e39. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11551-test-E2E-coverage-for-painter-widget-Levels-1-5-34a6d73d36508154a90fd24ffb3adb5b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-23 23:13:52 -04:00
Kelly Yang
3737a32999 test: extend minimap e2e for level 1& level4 (#11192)
## Summary

Adds Playwright coverage for minimap **Level 1** gaps and **Level 4**
drag-to-pan behavior (`browser_tests/tests/minimap.spec.ts`).

## Changes

- [x] **Level 1.1** — After closing the minimap with the X button,
assert `Comfy.Minimap.Visible` is `false` (in addition to minimap hidden
and toolbar toggle still visible).
- [x] **Level 1.2** — New `@mobile` + `@canvas` suite: on a narrow
viewport (Pixel 5 project), assert default `Comfy.Minimap.Visible` is
`false`, minimap container is absent (`toHaveCount(0)`), and the toolbar
minimap toggle remains visible.
- [x] **Level 1.3** — New test: close via X, re-open via toolbar toggle;
assert minimap and viewport are visible and `Comfy.Minimap.Visible` is
`true`.
- [x] **Level 4.1** — Pointer drag on `minimap-interaction-overlay`
(`pointerdown` / `pointermove` / `pointerup`); use `expect.poll` on main
canvas offset so each move step updates pan; assert meaningful total pan
distance after drag.
- [x] **Level 4.2** — Same drag pattern; assert `.minimap-viewport`
`style.transform` updates mid-drag via `expect.poll` between move steps.
- [x] **Helpers** — `clientPointOnMinimapOverlay`,
`readMainCanvasOffset`, shared `MINIMAP_POINTER_OPTS` (no
`waitForTimeout`).

## Notes

- [x] Desktop minimap tests unchanged in intent; mobile-only cases live
in a separate `test.describe` so `beforeEach` does not force
`Comfy.Minimap.Visible` on small screens.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright e2e tests and shared test
utilities, with no production logic modifications. Main risk is
increased test flakiness due to pointer-event simulation and
timing/polling on canvas state.
> 
> **Overview**
> Expands minimap Playwright coverage by adding shared helpers
(`minimapUtils.ts`) for overlay-relative pointer coordinates, consistent
pointer event options, and reading the main canvas offset.
> 
> Updates `minimap.spec.ts` to assert the `Comfy.Minimap.Visible`
setting toggles correctly when closing/reopening, adds
FitView+minimap-center regression coverage, and replaces mouse-drag with
explicit `pointerdown`/`pointermove`/`pointerup` overlay interactions
that assert progressive canvas panning and viewport `transform` updates.
Adds a new `@mobile` suite verifying the minimap is hidden by default on
small viewports while the toolbar toggle remains visible.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7fad2a7dd3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11192-test-extend-minimap-e2e-for-level-1-gaps-and-drag-panning-3416d73d36508194bc81f81a6d4536b8)
by [Unito](https://www.unito.io)
2026-04-23 22:17:16 -04:00
Alexander Brown
39e1877065 ci: filter e2e workflow on PRs to skip unrelated changes (#11568)
## Summary

Skip the main e2e test suite on PRs that only touch unrelated paths
(website, docs, storybook, markdown).

## Changes

- **What**: Replace the broad `paths-ignore: ['**/*.md']` on the
`pull_request` trigger with a more targeted `paths-ignore` list covering
`apps/**`, `docs/**`, `**/*.md`, and `.storybook/**`. The `push` (to
main), `merge_group`, and `workflow_dispatch` triggers remain
unconditional.

## Review Focus

- The `merge_group` trigger has no path filter, so the merge queue
always runs e2e as a safety net before merge.
- Using `paths-ignore` (denylist) rather than `paths` (allowlist) so new
top-level directories trigger e2e by default.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11568-ci-filter-e2e-workflow-on-PRs-to-skip-unrelated-changes-34b6d73d365081ea8603ef94bc86b6e6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 23:02:03 +00:00
Dr.Lt.Data
bd96bdf4cc fix(manager): migrate 4 endpoints GET→POST for CSRF hardening (#11520)
## Summary

Align `comfyManagerService` and Manager UI state with CSRF hardening in
[Comfy-Org/ComfyUI-Manager#2818](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2818)
(4.2.0, Content-Type gate + GET→POST migration) and
[Comfy-Org/ComfyUI-Manager#2823](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2823)
(4.2.1, `extension.manager.supports_csrf_post` feature flag).

## Changes

- **Service layer**: Convert 4 state-mutation endpoints (`START_QUEUE`,
`UPDATE_ALL`, `UPDATE_COMFYUI`, `REBOOT`) from GET to POST. `body=null`
+ axios default `Content-Type: application/json` is allowed by the
backend's `reject_simple_form_post` gate (only the three CORS
simple-form types are rejected).
- **UI/state layer**: Add `ManagerUIState.INCOMPATIBLE` triggered when
the backend advertises `supports_manager_v4` but not
`supports_csrf_post`. Manager UI is treated as "not installed" — buttons
hide via `shouldShowManagerButtons` with zero call-site changes across
`TopMenuSection`, `MissingNodeCard`, `MissingPackGroupRow`, `TabErrors`.
- **Graceful degraded mode**: One-shot upgrade toast (warn, 15s)
dispatched via `watch(immediate:true)` with a module-level guard that
survives multiple composable instances. `openManager()` re-emits on
explicit user action so stale shortcuts still surface guidance. i18n
(en/ko) covering Desktop / standalone pip / Manager UI self-update
paths.
- **Breaking**: None. Existing policies preserved (`--enable-manager`
absent → `DISABLED`; `--enable-manager-legacy-ui` → `LEGACY_UI`; feature
flags not yet loaded → `NEW_UI` transient fallback).

## Review Focus

- Decision-tree ordering in `useManagerState.ts`: `supports_csrf_post`
check evaluates before `NEW_UI`/`LEGACY_UI` branches so stale Manager
backends never reach the enabled paths.
- Toast guard: module-level `incompatibleToastShown` survives multiple
composable instances (tests verify 3× `useManagerState()` = 1 toast
call).
- `generatedManagerTypes.ts` still declares the 4 endpoints as GET;
regeneration follows once Manager 4.2.1 OpenAPI is published. Runtime is
unaffected since axios operates on the route string.

## References

-
[Comfy-Org/ComfyUI-Manager#2818](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2818)
— CSRF Content-Type gate + GET→POST migration (4.2.0)
-
[Comfy-Org/ComfyUI-Manager#2823](https://github.com/Comfy-Org/ComfyUI-Manager/pull/2823)
— `supports_csrf_post` feature flag (4.2.1)
- [comfyui-manager 4.2.1 on
PyPI](https://pypi.org/project/comfyui-manager/4.2.1) — release package
2026-04-23 15:53:19 -07:00
Dante
559922eaa5 feat: add bug-dump-ingest skill (#11460)
## Summary

Adds a new Claude Code skill at `.claude/skills/bug-dump-ingest/` that
syncs the `#bug-dump` Slack channel into Linear as the system of record,
per discussion in [this
thread](https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1776510375473579).

- Primary mode is bulk sync — every ingestable top-level message becomes
a Linear issue in the Frontend Engineering team's Triage state with
labels for area / env / severity / reporter.
- Marks handled messages via the team emoji scheme:
  - `` — ticket created
  - `:pr-open:` — fix PR open
  - `` — needs more context
  - `🔁` — duplicate
- Since the Slack reactions API isn't exposed to the skill, the
machine-readable marker is a thread reply carrying the Linear URL; the
human is prompted to add the visible parent reaction from a batch list
printed at session end.
- Secondary opt-in per-row mode delegates to `red-green-fix` to author a
failing unit + e2e test, then a minimal fix, then a PR.

## Files

- `SKILL.md` — entry point: workflow, classification, verification,
approval flow, Linear integration (MCP / GraphQL / draft fallback), fix
workflow
- `reference/linear-api.md` — GraphQL snippets for teams / states /
labels / issues
- `reference/schema.md` — field-by-field extraction rules
- `reference/examples.md` — seven worked examples from real recent
`#bug-dump` messages
- `reference/verify-commands.md` — cookbook of false-defect verification
commands

## Linear MCP setup

Two supported paths documented in `SKILL.md`:

- Option A: official hosted Linear MCP at `https://mcp.linear.app/sse`
via `claude mcp add`, OAuth-based, no API key.
- Option B: community self-hosted MCP with `LINEAR_API_KEY` in env.

## Test plan

- [ ] Restart Claude Code session after merging so the Linear MCP tools
register
- [ ] Authorize Linear OAuth on first tool call
- [ ] Dry-run the skill against a 48h window of `#bug-dump`, confirm the
approval table renders with all 9 columns
- [ ] File one real ticket end-to-end; verify labels, Triage state,
Slack thread reply, and permalink attachment
- [ ] Add a `` reaction on that parent; re-run and
confirm the message is skipped

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11460-feat-add-bug-dump-ingest-skill-3486d73d3650810094aee3e4ee79eb86)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-24 07:20:35 +09:00
Christian Byrne
4b7a027946 fix: route progress_text feature flag check through getDevOverride (#11384)
## Summary

Route the `progress_text` binary parser's feature-flag check through
`serverSupportsFeature()` so dev overrides via `localStorage` take
effect.

## Changes

- **What**: Replace
`this.getClientFeatureFlags()?.supports_progress_text_metadata` with
`this.serverSupportsFeature('supports_progress_text_metadata')` in the
`case 3` binary message handler, consistent with all other feature-flag
checks in the class.

## Review Focus

Minimal one-line change. The key consideration is that
`serverSupportsFeature()` routes through `getDevOverride()` first,
enabling `localStorage` overrides (`ff:supports_progress_text_metadata`)
for dev testing of the binary wire format.

Fixes #11187

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11384-fix-route-progress_text-feature-flag-check-through-getDevOverride-3476d73d36508161bca0d6c2ea7c3c55)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 15:04:18 -07:00
Christian Byrne
cc1338e51e perf: exclude canvas nodes from PostHog session recording (#10494)
## Summary

Add `ph-no-capture` class to TransformPane to block PostHog session
recording of canvas node DOM mutations, eliminating 226ms CPU overhead
in the viewport scenario.

## Changes

- **What**: Add `ph-no-capture` CSS class to the TransformPane root div
and a unit test guarding it
- **Why**: Profiling showed PostHog session recording (via rrweb
mutation observer) consuming **226ms CPU** in the viewport scenario —
**9× more** than the entire Vue reactivity system (25ms). The 150+
LGraphNode Vue components that mount/unmount during pan/zoom each
trigger rrweb to snapshot new DOM subtrees.

### How it works

PostHog uses rrweb under the hood. The `ph-no-capture` class maps to
rrweb's `blockClass`, which causes:
1. `processMutation` to **early-return** for `childList` mutations when
the parent is blocked
2. `genAdds` to **skip child traversal** (`dom.childNodes()`) for
blocked elements
3. The element to be replaced with a **same-size placeholder** in replay

This means all 150+ node components inside TransformPane produce **zero
mutation processing cost**.

### Scope

- **TransformPane**: blocked — wraps all Vue-rendered graph nodes
- **LinkOverlayCanvas**: evaluated but not blocked — contains only a
single `<canvas>` element with no DOM children, so no mutation overhead
- **All other UI** (sidebar, menus, dialogs, toolbar, bottom panel):
continues recording normally

### Trade-off

The graph canvas area appears as a blank placeholder in session replays.
This is acceptable because canvas interaction is better captured via
workflow JSON, and the performance gain far outweighs the replay
fidelity loss.

## Review Focus

- Correctness of the `ph-no-capture` placement on TransformPane as the
optimal DOM boundary
- Whether any other high-mutation DOM subtrees should also be blocked

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10494-perf-exclude-canvas-nodes-from-PostHog-session-recording-32e6d73d36508169ab07f1b193860fb0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-04-23 15:03:41 -07:00
pythongosssss
0052cdadd4 test: e2e coverage for node templates (#11564)
## Summary

Add E2E tests for node templates

## Changes

- **What**: 
- add tests for save, insert, delete, import export
- vue and litegraph
- add testid to dialog
- update `clickLitegraphMenuItem` to enable clicking children with the
same name as parent

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11564-test-e2e-coverage-for-node-templates-34b6d73d365081a39ce5c713f05a2a92)
by [Unito](https://www.unito.io)
2026-04-23 19:33:18 +00:00
Christian Byrne
aa40dd8a65 [chore] Update Comfy Registry API types from comfy-api@315429a (#11566)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 315429a
- Generated on: 2026-04-22T21:35:59Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11566-chore-Update-Comfy-Registry-API-types-from-comfy-api-315429a-34b6d73d3650815dbe21f1960115dc47)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-23 18:21:03 +00:00
Alexander Brown
68798d8e37 feat(website): website mise en place (#11552)
## Summary

Assorted website copy and content refinements — tidying up loose ends
across the site.

## Changes

- **What**: Remove placeholder doc links from custom nodes feature
description on pricing page

## Review Focus

Low-risk copy changes only; no logic or layout modifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11552-feat-website-website-mise-en-place-34b6d73d3650813b954afbc965e4dc74)
by [Unito](https://www.unito.io)

> **Note:** The `PR: Vercel Website Preview` workflow is
`workflow_run`-triggered, so it always runs the **main branch version**
of the workflow file. Until this PR is merged, the preview workflow will
continue posting standalone comments using the old `<!--
VERCEL_WEBSITE_PREVIEW -->` marker instead of writing to the
consolidated `<!-- WEBSITE_CI_REPORT -->` comment. This is expected and
resolves itself on merge.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-04-23 17:26:06 +00:00
pythongosssss
a6c3ff1a54 fix: load3d used wrong i18n key, add test (#11546)
## Summary

Toast for Load3D initialization failure was using the wrong key and so
showed an untranslated key to the user.

## Changes

- **What**: 
- Update to use correct existing key
- Add test that forces init failure

## Screenshots (if applicable)
Fixed
<img width="482" height="121" alt="image"
src="https://github.com/user-attachments/assets/f89eef99-c1a6-463a-a711-7e9c16d0e89a"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11546-fix-load3d-used-wrong-i18n-key-add-test-34a6d73d36508159aab9f042d3e9c4f0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 11:52:42 -04:00
Dante
9599a4e00a fix cloud frontend runtime guard regressions (#11180)
## Summary
- harden cloud frontend runtime paths that were throwing on
`cloud.comfy.org`
- guard widget value propagation when the source widget is missing
- treat nullish executed outputs as empty output during flatten/parsing
- ignore stale autogrow disconnect callbacks after an autogrow group is
removed

## Root cause
This PR bundles three small runtime guard fixes from
`cloud-frontend-staging` issues that reproduce on
`https://cloud.comfy.org/`:

- `CLOUD-FRONTEND-STAGING-429`: widget propagation assumed
`this.widgets[0]` always existed and crashed during group-node/widget
lifecycle transitions
- `CLOUD-FRONTEND-STAGING-3QA` and sibling `3QB`: executed-event parsing
assumed `detail.output` was always an object and crashed on nullish
output payloads
- `CLOUD-FRONTEND-STAGING-42B`: `autogrowInputDisconnected()` could run
from a stale `requestAnimationFrame()` callback after its autogrow group
had already been removed

## User impact
- prevents unhandled frontend exceptions on `cloud.comfy.org`
- keeps node output rendering and linear-mode flattening resilient to
sparse executed payloads
- avoids autogrow disconnect crashes during graph/widget churn

## Changes
- extracted shared widget propagation logic into
`widgetValuePropagation.ts`
- added source-widget guards in custom widget / primitive widget
propagation paths
- added null guards in result parsing and linear-mode output flattening
- added an autogrow-group existence guard in `dynamicWidgets.ts`
- added focused regression tests for all three bug shapes

## Red / Green Verification
### Red
I ran the new targeted regression suite in a temporary pre-fix worktree
with the runtime guards reverted while keeping the new tests.

Failing tests in that state:
- `src/extensions/core/widgetValuePropagation.test.ts`
  - `returns early when the source widget is missing`
- `src/stores/resultItemParsing.test.ts`
  - `returns empty array for nullish node output`
  - `ignores nullish node outputs`
- `src/renderer/extensions/linearMode/flattenNodeOutput.test.ts`
  - `returns empty array for nullish node output`

Representative pre-fix errors:
- `TypeError: Cannot read properties of undefined (reading 'value')`
- `TypeError: Cannot convert undefined or null to object`

### Green
On the draft PR branch, the targeted regression suite passes:
- `pnpm exec vitest run src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/widgetValuePropagation.test.ts
src/extensions/core/customWidgets.test.ts`

Result:
- `5` test files passed
- `49` tests passed

## Validation
- `pnpm exec vitest run src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/widgetValuePropagation.test.ts
src/extensions/core/customWidgets.test.ts`
- `pnpm exec eslint --no-ignore src/core/graph/widgets/dynamicWidgets.ts
src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.ts src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/customWidgets.ts src/extensions/core/widgetInputs.ts
src/extensions/core/widgetValuePropagation.ts
src/extensions/core/widgetValuePropagation.test.ts`
- `pnpm exec oxfmt --check src/core/graph/widgets/dynamicWidgets.ts
src/core/graph/widgets/dynamicWidgets.test.ts
src/stores/resultItemParsing.ts src/stores/resultItemParsing.test.ts
src/renderer/extensions/linearMode/flattenNodeOutput.ts
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
src/extensions/core/customWidgets.ts src/extensions/core/widgetInputs.ts
src/extensions/core/widgetValuePropagation.ts
src/extensions/core/widgetValuePropagation.test.ts`

## Notes
- I explicitly skipped the `getCanvas: canvas is null` issue because it
is already covered by open PRs `#11173` / `#11174`.
- `pnpm typecheck` was not included in validation because the temporary
PR worktree used for publication hits local path-resolution issues
through the shared dependency install, which is unrelated to the changes
in this PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11180-codex-fix-cloud-frontend-runtime-guard-regressions-3416d73d365081e0af6ec612c9d0d8aa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 12:02:46 +00:00
pythongosssss
cc73baaf57 test: Test subgraph breadcrumbs (#11472)
## Summary

Add test coverage to subgraph breadcrumbs

## Changes

- **What**: 
- Add subgraph breadcrumb helpers
- Add tests for entering/navigating/menu/collapsing

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11472-test-Test-subgraph-breadcrumbs-3486d73d365081e1a75bf57404eaa63b)
by [Unito](https://www.unito.io)
2026-04-23 01:49:29 -07:00
Dante
700fcb6bda test: add unit tests for FormDropdownMenuActions (#11443)
## Summary

Adds 14 unit tests for \`FormDropdownMenuActions\` — isolated into its
own PR because the component is denser (three PrimeVue Popovers,
multiple filter models) than the sibling components in the form-dropdown
PR. Part of a widget-test-coverage sequence.

## Changes

- **What**: \`FormDropdownMenuActions.test.ts\` — search v-model, sort
popover options + selection, ownership popover visibility gated by
\`showOwnershipFilter\` + options present, base-model multi-select
toggle (add/remove/multiple), Clear Filters, list/grid layout-mode
v-model.

## Review Focus

- PrimeVue \`Popover\` stubbed as an always-slotted \`<div>\` with
\`toggle\`/\`hide\` methods on the Options-API \`methods\` (stub
\`expose\` did not satisfy template-ref access).
- Sort/ownership/base-model option discovery uses
\`within(popover-body)\` to disambiguate buttons across the three
popovers.
- Layout-switch locator uses an \`.icon-*\` class probe since the button
has no accessible name; covered by an \`eslint-disable-next-line
testing-library/no-node-access\`.
- No changes to the component source.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11443-test-add-unit-tests-for-FormDropdownMenuActions-3486d73d365081ed80dcfdf5d83655e1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-23 08:00:54 +00:00
717 changed files with 56206 additions and 8594 deletions

View File

@@ -0,0 +1,695 @@
---
name: bug-dump-ingest
description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.'
---
# Bug Dump Ingest
**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically.
```text
fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals
→ POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call)
→ poll slack_read_thread → parse Linear bot card for FE-NNNN + URL
→ POST :white_check_mark: confirmation thread reply via slack_send_message
→ if verification = "real bug" AND no dedupe AND no open PR:
invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply
```
### Non-negotiable rules
1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt.
2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`.
3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt.
### What the skill cannot do
The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection.
## Team emoji scheme
| Emoji | Meaning | Who adds it | Skill behavior |
| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- |
| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps |
| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row |
| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification |
| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue |
## Design Priority
Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR.
## Quick Start
1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list.
2. **Fetch**`slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies.
3. **Filter** — drop already-processed (see Processed Detection).
4. **Classify** — bug / discussion / meta (see Classification Rules).
5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create.
6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification).
7. **Extract** — normalize to ticket schema (see Ticket Schema).
8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix).
9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute.
10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one.
11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: <URL>` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log.
12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract.
13. **Log** — append to session log; update `processed.json`.
## System Context
| Item | Value |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| Source channel | `#bug-dump` (`C0A4XMHANP3`) |
| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. |
| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` |
| State dir | `~/temp/bug-dump-ingest/` |
| Processed registry | `~/temp/bug-dump-ingest/processed.json` |
| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` |
| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually |
## Label Taxonomy
Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use:
| Label kind | Values | Source |
| ------------ | ------------------------------------------------------------------------------ | ------------------------- |
| `source:` | `source:bug-dump` | Always (marks Slack sync) |
| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics |
| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics |
| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics |
| `reporter:` | `reporter:<slack-handle>` (kebab-case) | From message author |
| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable |
Label rules:
- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`.
- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix".
- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo.
- `regression` — set when the bug mentions a version/upgrade correlation.
- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker.
Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title.
## Processed Detection
A top-level message is considered already-handled (skip creation) if ANY of:
- Its timestamp appears in `processed.json`.
- It carries a `:white_check_mark:` reaction on the parent — ticket already created.
- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue.
- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log.
- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up.
- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`).
- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user.
- It is a system/meta message (`has joined the channel`, bot-only message).
- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip".
Never re-ingest a message already marked in any of the above ways.
Filter query for Slack search-based sweeps:
```text
in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD
```
## False-Defect Verification
Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes.
| Check | Command / Signal | Demote-to |
| ---------------------------------------- | ---------------------------------------------------------------- | ---------- |
| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` |
| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` |
| Fix PR merged on main | `gh search prs "in:title <keyword>" --state merged --limit 3` | `fixed` |
| Fix PR open (already-filed) | `gh search prs "<keyword>" --state open --limit 3` | `pr-open` |
| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` |
| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` |
| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` |
| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` |
For each demoted candidate, record the demotion reason in the approval table as `Verify: <tag>` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion.
### Recommended verify commands
```bash
# 1. Search recent PRs for the feature in question
gh search prs "<keyword>" --repo Comfy-Org/ComfyUI_frontend --limit 5
# 2. Grep for the feature / component mentioned
rg -l "<ComponentOrFeatureName>" src/ apps/
# 3. Check if it's a known i18n / documented setting
rg "<setting-key>" src/locales/en/ docs/
```
Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck.
## Classification Rules
For each unprocessed top-level message, decide:
| Class | Signal | Action |
| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket |
| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip |
| **question** | User asking if something is expected or known | Skip unless answered = bug |
| **meta** | Channel joins, bot messages, cross-posts without content | Skip |
| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link |
When ambiguous, default to **bug** and let the human decide in the approval batch.
## Ticket Schema
Normalize each bug to this shape before presenting:
```json
{
"slack_ts": "1776639963.837519",
"slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519",
"reporter": "Ali Ranjah (wavey)",
"title": "Unet model dropdown missing selected model",
"description": "Body with repro steps, env, attachments list, thread summary",
"env": ["cloud prod"],
"severity": "low | medium | high",
"area": "ui | node-system | workflow | cloud | templates | unknown",
"attachments": [{ "name": "...", "id": "F...", "type": "image/png" }],
"thread_resolution": "solved | open | none"
}
```
Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: <permalink>". Attach thread summary only if it adds context beyond the top-level message.
### Severity Heuristics
- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset").
- **medium** — visible regression, template error, wrong pricing, broken UX on a common path.
- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue.
When unsure, mark `medium` and flag for human in the approval batch.
### Area Heuristics
- `ui` — visual glitches, palette issues, popover clipping, dropdown styling.
- `node-system` — canvas perf, reroute, node drag, widget rendering, undo.
- `workflow` — template failures, save/load, refresh regressions.
- `cloud` — jobs, pricing, assets, auth, queue.
- `templates` — specific template errors.
## Pre-flight Dedupe Gate (MANDATORY)
Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict.
### Check 1 — Open Linear issues (via `@Linear search`)
Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
})
```
Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class.
If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel:
```text
mcp__plugin_slack_slack__slack_search_public({
query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
})
```
This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log.
Treat a hit as a duplicate if any of:
- Title overlap ≥ 80% (after lowercasing + stopword removal)
- Same reporter + same component reference in description
- Same stack trace or error code
**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket.
### Check 2 — Open or merged fix PRs on GitHub
```bash
# Open PRs matching title keywords
gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \
--search "<keyword-1> <keyword-2>" --limit 5 \
--json number,title,url,createdAt
# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship"
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
--search "<keyword-1> <keyword-2> merged:>=<YYYY-MM-DD>" --limit 5 \
--json number,title,url,mergedAt
```
Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation.
**Verdict:**
- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix).
- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land).
### Failure handling
If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets.
Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit.
## Interactive Approval
Present candidates in batches of 5-10. Table format (10 columns):
```text
# | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec
----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+-----
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N
2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y
3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L
4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open
```
Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation.
### Default recommendation logic
The skill computes `Rec` deterministically from the gate results:
- `L` — Dedupe hit on open Linear issue.
- `pr-open` — Open GitHub PR hit.
- `fixed` — Merged PR hit within the reporter's observation window.
- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only.
- `?` — Repro incomplete or classification ambiguous.
- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path.
- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix.
### Response format
- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow.
- `S`**skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes.
- `N` — skip entirely (log reason in session file).
- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent.
- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match).
- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent.
- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks).
- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default.
Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body.
## Linear Slack Bot Integration (@Linear)
Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale.
### Prerequisites
- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today).
- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team.
- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently.
### Create an issue
For each approved `Y` candidate, call:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>"
})
```
Rules:
- First line MUST be `@Linear create` — this is the command token.
- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team.
- `Status: Triage` pins the initial state (per § System Context).
- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use.
- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction.
- Use real newlines (not literal `\n`) when constructing the text.
After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for:
- An `FE-NNNN` identifier
- A `https://linear.app/<org>/issue/FE-NNNN` URL
The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply).
### Search (dedupe)
See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside.
### Link an existing issue (`L` response)
When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear link FE-4521"
})
```
The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches.
### Label / status updates
When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link):
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 add-labels pr-open"
})
```
Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body.
### Captured fields per create
Every successful create must produce, via the Linear bot's reply card:
- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log
- `url``https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply
- `ts` of the Linear bot's card reply — recorded in session log for audit
If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value.
### Failure path
If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`):
1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply.
2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL:
```text
:warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN.
```
3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference).
4. Log the failure in the session log.
Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card.
## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL
Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread:
1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration.
2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card.
The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug.
The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested.
### Required call shape
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509"
text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>"
})
```
Rules:
- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection.
- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`.
- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug.
- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text.
- Capture the returned `ts` and record it in the session log for audit.
### NEVER-do list (common failure mode)
- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead.
- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply.
- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages.
- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail.
### Fix-path reply (after red-green-fix opens a PR)
When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`:
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<same parent ts>",
text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>"
})
```
Same "tool call, not chat output" rule applies.
### Parent reaction — optional visibility nudge (not on critical path)
The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human:
```text
Optional: add :white_check_mark: to parent messages for in-channel visibility.
LIN-4710 → <permalink>
LIN-4711 → <permalink>
```
This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion.
## Fix Workflow (auto-invoke red-green-fix)
For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff.
### Required Skill tool call
```text
Skill({
skill: "red-green-fix",
args: "<composed prompt — see below>"
})
```
Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue:
```text
Bug: <title>
Linear: <LIN-ID> (<LINEAR_URL>)
Source: Slack <permalink>
Reporter: <display-name>
Env: <env tags>
Area: <area>
Branch: fix/<lin-id-lowercase>-<short-slug>
Repro:
1. <step>
2. <step>
Expected: <expected behavior>
Actual: <actual behavior>
Test layer (inferred from area):
- ui → Vitest colocated + Playwright e2e tagged @regression
- node-system → Playwright e2e primarily
- workflow / templates → Playwright e2e
- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend"
Test naming:
- describe('<LIN-ID>: <one-line bug summary>', ...)
- Playwright test title must include the LIN-ID.
PR body must include:
- "Fixes <LIN-ID>"
- "Source: Slack <permalink>"
Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit.
```
The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic.
### Verifying the invocation ran
After the `Skill` call returns, the skill MUST confirm at least one of:
1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`).
2. A PR URL is present in `red-green-fix`'s return payload.
If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply.
### Inputs summary
- **Bug description** — the Linear description (includes repro, env, source permalink).
- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`.
- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`).
- **Test layer** — inferred from `area`:
- `ui` → unit (Vitest) + e2e (Playwright)
- `node-system` → e2e primarily; unit if isolable
- `workflow` / `templates` → e2e
- `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo"
### Handoff-Exclusion list (do NOT auto-invoke red-green-fix)
These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why:
- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?"
- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`.
- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing.
- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only.
- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision).
- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`).
When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`.
### Test authoring rules
Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion:
- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID:
```typescript
// src/components/node/UnetDropdown.test.ts
describe('LIN-4710: unet dropdown missing selected model', () => {
it('includes the currently-selected model in the list even when not in available models', () => {
// ...
})
})
```
- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title:
```typescript
test.describe(
'LIN-4710 unet dropdown regression',
{ tag: ['@regression'] },
() => {
test('keeps selected model visible in the dropdown', async ({
comfyPage
}) => {
// ...
})
}
)
```
- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`.
(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.)
### PR body template
The red-green-fix skill's PR template is extended with a `Source` line:
```markdown
## Summary
<Root cause>
- Fixes LIN-NNN
- Source: Slack <permalink>
## Red-Green Verification
| Commit | CI Status | Purpose |
| ------------------------------------------ | -------------------- | ------------------------------- |
| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug |
| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug |
## Test Plan
- [ ] Unit regression test passes locally
- [ ] E2E regression test passes locally (if UI)
- [ ] Manual repro no longer reproduces
- [ ] Linear ticket linked
```
After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply).
## Emoji Reaction Hints (read-only)
The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary):
| Reaction | Meaning | Action |
| -------------------- | ------------------ | -------------------------------------------------------- |
| `:white_check_mark:` | Ticket created | Skip — already ingested |
| `:pr-open:` | PR open | Skip creation; record PR link in session log |
| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking |
| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log |
Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these:
| Reaction | Meaning | Action |
| -------- | ------------------- | -------------------------------------------------- |
| `:eyes:` | Someone is triaging | Still ingestable |
| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row |
| `:+1:` | Acknowledged | Ignore |
Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID.
## Session Log
Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`:
```text
Bug Dump Ingest Session -- 2026-04-20 11:40 KST
Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST
Scanned: 28 top-level messages
Skipped (meta/discussion/processed): 14
Proposed: 14
Approved: 11
Created in Linear: 10
Draft-only (creation failed): 1
Linked-only (dedupe): 1
Thread replies posted: 11
Created:
- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui
- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud
- ...
Skipped with reason:
- 1776592837.616399 -- design discussion in thread, not a bug
- ...
```
## Gotchas
### Thread summaries, not raw dumps
Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter.
### Cross-posts are not bugs
When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself.
### Resolved-in-thread messages
If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket.
### Permalinks
Construct Slack permalinks as:
```text
https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED}
```
E.g. `1776510375.473579` → `p1776510375473579`.
### Attachment handling
Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread.
### No auto-create without approval
Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment.
## Reference Files
- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status).
- `reference/schema.md` — full ticket schema with field-by-field extraction notes.
- `reference/examples.md` — worked examples drawn from real #bug-dump messages.
- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class.
## Related Skills
- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof.
- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed.
- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill.

View File

@@ -0,0 +1,123 @@
# Worked Examples
Real #bug-dump messages (2026-04-17 → 2026-04-20) normalized through the skill.
## Example 1 — Clean bug with repro
**Source message** (wavey, 2026-04-20 08:06):
> unet model dropdown doesnt display all available models, think this is part of a larger issue with model dropdowns..
>
> • open flux.2 klein 4b image edit template
> • open unet drop down --> notice selected model isnt present in the list, even though its selected
> • execute (to check if it flags the model as missing) --> notice it still runs
> No action needed, this is solved
**Thread resolution**: "No action needed, this is solved" — reporter resolved it in the same message.
**Classification**: bug, but `thread_resolution = solved`. Flag for human.
**Approval row**:
```text
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud | low | ui | N | N (reporter marked solved)
```
Default recommendation: `N`. If human overrides to `Y`, file with a "Regression test" label so QA still tracks it.
---
## Example 2 — Clear high-severity cloud bug
**Source message** (Denys Puziak, 2026-04-18 05:45):
> I see two reports about jobs ending in 30 minutes while the user is on the Pro plan
> cc @Hunter
> https://discord.com/channels/.../1494078128971055145
**Classification**: bug, `env: [cloud prod]` (Pro plan = cloud), `severity: high` (paying users), `area: cloud`.
**Proposed title**: `Pro plan jobs end at 30 minutes`
**Description** (excerpt):
```markdown
**Reporter:** Denys Puziak
**Env:** cloud prod
**Severity (proposed):** high
**Area:** cloud
## Repro
1. User on Pro plan submits a job
2. Job ends at 30 minutes instead of the Pro plan limit
## Notes
- Two user reports aggregated by Denys
- cc'd @Hunter
## Source
Slack: <permalink>
Discord thread: https://discord.com/channels/.../1494078128971055145
```
---
## Example 3 — Not a bug (discussion)
**Source message** (Christian Byrne, 2026-04-19 19:00):
> @Glary-Bot okay option A is clearly superior and I feel embarrassed I didn't see that line myself...
**Classification**: discussion (design review chatter). Skip. Log reason in session file.
---
## Example 4 — Meta-action / PR planning
**Source message** (Christian Byrne, 2026-04-19 09:30):
> @Glary-Bot how about we make a PR to do:
>
> 1. Audit the rest of the codebase...
> 2. Create a helper in src/base...
**Classification**: discussion (PR-plan proposal). Skip.
---
## Example 5 — Performance regression
**Source message** (Terry Jia, 2026-04-18 12:52):
> With Nodes 2.0, large workflows (hundreds of nodes) make the canvas extremely laggy and unusable for actual work — switching tabs takes several seconds or more. Switching back to Litegraph, performance is significantly better.
**Classification**: bug, `area: node-system`, `severity: high`.
**Dedupe**: Post `@Linear search nodes 2.0 performance canvas lag` (Team: Frontend Engineering, Status: open) in the candidate's thread. Likely matches exist — flag `Dedup? ?` and ask human which ticket to link to.
---
## Example 6 — Reporter says it's a question, not a report
**Source message** (Luke, 2026-04-17 08:27):
> Is NodeInfo supposed to show information or docs about the node? It just brings up the node sidebar
**Classification**: question → ambiguous. Read thread. If replies confirm "that's unexpected, should show docs", upgrade to bug. If "yes that's intended", skip.
Default recommendation in the approval batch: `?` (needs expansion).
---
## Example 7 — Bug with PR already in flight
**Source message** (Pablo, 2026-04-17 08:52):
> when deleting multiple assets on cloud -> the confirmation popup still has the assets hashes as names instead of the display name
**Reaction**: `pr-open (1)` — someone's opened a PR.
**Classification**: `already-filed` branch. Skip creation; in the session log, note "PR already open". If the human wants a tracking Linear ticket anyway, still fileable with a link to the PR.

View File

@@ -0,0 +1,160 @@
# Linear Slack Bot (@Linear) Reference
The skill drives Linear exclusively through the Linear Slack app (`@Linear`). **There is no Linear MCP, no `LINEAR_API_KEY`, no GraphQL.** Every Linear read/write happens as a Slack message that mentions `@Linear` in the `#bug-dump` thread, and the Linear Slack app performs the action and posts a reply card containing the issue URL.
## Why Slack-only
- The `#bug-dump` thread is already the source of truth; keeping the entire lifecycle (report → ticket → PR → resolution) in one thread means Processed Detection can grep the thread instead of a separate registry.
- No API key rotation, no MCP server install, no OAuth browser flow — works on any machine that already has the Slack MCP configured.
- The Linear Slack app's reply card (with issue URL, title, status, and assignee) IS the canonical receipt; the skill records its `ts` in the session log.
## Prerequisites (one-time, per workspace)
The Comfy Slack workspace must already have the Linear Slack app installed (it is — that's how humans use `@Linear` reactions today) and `#bug-dump` (channel `C0A4XMHANP3`) must have Linear enabled for the `Frontend Engineering` team. Nothing else to configure. If a `@Linear` invocation silently does nothing, the bot isn't present in the channel — surface that to the human rather than re-trying.
## Supported operations
Every operation is a `mcp__plugin_slack_slack__slack_send_message` call with `channel_id=C0A4XMHANP3` and `thread_ts=<parent-ts>`. The `text` is a natural-language instruction to the Linear bot. Keep the text concise — Linear parses the first line as the command intent.
### 1. Create an issue from the thread
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description body>\n\nSource: <slack-permalink>"
})
```
Rules:
- Start with `@Linear create` on its own line — this is the command token the bot keys on.
- Always specify `Team: Frontend Engineering`. Without it, the bot falls back to the Slack workspace's default team, which may not be FE.
- `Status: Triage` pins the initial workflow state.
- `Labels:` — comma-separated. If a label doesn't exist yet in Linear, the bot creates it on first use (verified in Linear workspace settings). Keep the taxonomy exactly as SKILL.md § Label Taxonomy.
- `<description body>` — markdown per `reference/schema.md` Description Template. Use real newlines, not literal `\n`.
- End with `Source: <slack-permalink>` so the Linear issue body links back even if the auto-attachment of the parent message fails.
The Linear bot replies in the same thread with a card that contains:
- The Linear URL (`https://linear.app/comfy-org/issue/FE-NNNN`)
- Status, assignee (initially unassigned), and applied labels
- A "View in Linear" button
Parse the URL out of the bot's reply text (or attachments). If no card reply appears within ~10s of polling `slack_read_thread`, treat it as a creation failure — do NOT proceed to the `:white_check_mark:` confirmation reply.
### 2. Search existing open issues (dedupe)
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
})
```
The bot replies with a card listing up to ~5 matching open issues. Parse identifier (`FE-NNNN`) and URL per row. Treat a hit as a duplicate per SKILL.md § Pre-flight Dedupe Gate § Check 1.
If `@Linear search` is not supported in the installed Slack app version, fall back to Slack-native search across the `#bug-dump` thread replies (previous `@Linear` cards contain title + URL — grep those for the same keywords). Record which path was used in the session log so the human can see dedupe coverage.
### 3. Link an existing issue (dedupe: `L` response)
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear link FE-4521"
})
```
The bot replies with the linked issue card. The skill then posts its own `:white_check_mark: Linked to Linear: <URL>` confirmation reply (see SKILL.md § Slack Thread Reply).
### 4. Add labels to an existing issue
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 add-labels pr-open"
})
```
Used when an open PR is discovered after ticket creation and the Linear issue should flip to `pr-open`.
### 5. Change status
```text
mcp__plugin_slack_slack__slack_send_message({
channel_id: "C0A4XMHANP3",
thread_ts: "<parent-ts>",
text: "@Linear FE-4521 status In Progress"
})
```
Rarely used by the skill directly — usually status changes come from the `red-green-fix` PR lifecycle (Linear auto-moves to `In Review` when a PR references `Fixes FE-4521`).
## Description body template
The text that follows the command headers is rendered verbatim as the Linear issue description (markdown). Use this template — see `reference/schema.md` for field-by-field extraction notes:
```markdown
**Reporter:** <slack-display-name>
**Env:** cloud prod / local / electron / ...
**Severity (proposed):** high/medium/low
**Area:** ui / node-system / workflow / cloud / templates
## Repro
1. ...
2. ...
## Expected
...
## Actual
...
## Attachments (in Slack thread)
- image.png (png, 315 KB)
- Screen Recording.mov (mov, 37 MB)
## Source
Slack: <permalink>
Thread summary: <1-3 bullets if thread adds context>
```
The Slack permalink is load-bearing — it's the canonical route to attachments, reporter, and any follow-up discussion. Do NOT embed Slack file IDs (`F0AT...`) directly; they're permissioned.
## Parsing the bot's reply
After each `slack_send_message` that mentions `@Linear`, poll `slack_read_thread` (with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`) up to 3 times, ~3s apart. Scan replies authored by the Linear Slack app user for:
- Any `https://linear.app/<org>/issue/FE-\d+` URL → capture as the issue URL.
- The `FE-NNNN` identifier pattern → capture as the issue identifier.
- An error phrase (`couldn't`, `failed`, `not found`, `no team matched`) → treat as failure; surface the full bot text to the human.
Record the bot reply's `ts` alongside the captured URL and identifier in the session log.
## Failure modes & handling
| Symptom | Likely cause | Handling |
| ------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| No bot reply within 10s | Linear app not in channel, or bot outage | Halt the batch, surface to human, do NOT fabricate a Linear URL. Remaining approved candidates stay queued for re-run. |
| Bot replies with "no team matched" | Team name typo or Linear workspace drift | Re-send with the exact team name from the Linear workspace (default: `Frontend Engineering`). If it still fails, ask the human to verify. |
| Bot replies with "couldn't parse labels" | One of the labels has syntax the bot rejects | Drop the offending label, re-send; log the partial-label failure so the human can patch after. |
| Bot creates the issue but reply lacks the URL | Rare bot format change | Re-fetch the thread after ~5s; if URL still absent, open Linear search via `@Linear search <title>` and recover the identifier + URL. |
| Multiple `@Linear` replies match (duplicate card) | The skill retried without polling first | Keep the earliest card's URL; log the extras. Never re-issue `@Linear create` for the same candidate without confirming the first card failed. |
Never retry `@Linear create` without first running `@Linear search` for the same title keywords — a duplicate card is worse than an initial failure because the human has to close one of them manually.
## Why no direct API path
- The Linear MCP (official or community) would require either OAuth setup or `LINEAR_API_KEY` in env — both are per-machine hurdles the skill should not depend on.
- Direct GraphQL against `api.linear.app` has the same key-management cost and bypasses the Slack thread as the audit trail.
- Routing every action through `@Linear` in the thread gives humans full visibility in the channel (the bot's card is the receipt) and Processed Detection becomes a simple Slack thread read.
If a future need requires capabilities the `@Linear` Slack app doesn't expose (bulk operations, private field edits, webhooks), stop and surface the limitation to the human rather than quietly adding an API-key path — the "Slack-only" constraint is intentional.

View File

@@ -0,0 +1,94 @@
# Ticket Schema — Extraction Notes
Field-by-field guidance for normalizing a Slack #bug-dump message into a ticket.
## `slack_ts`
The top-level message timestamp from `slack_read_channel` response (`Message TS:` field). Always store the dotted form (`1776510375.473579`). This is the ingestion identity used in `processed.json`.
## `slack_permalink`
Construct:
```text
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p<ts-without-dot>
```
Example: `1776510375.473579``.../p1776510375473579`.
## `reporter`
The display name + parenthetical nickname if present. Examples from the channel:
- `Ali Ranjah (wavey)`
- `Denys Puziak`
- `Christian Byrne`
Do NOT use the Slack user ID (`U087MJCDHHC`) in Linear — names are more readable.
## `title`
Rules:
- Start with a verb or noun phrase describing the observed defect, not the reporter.
- ≤ 80 chars.
- Include env qualifier ("cloud prod", "local dev", "electron") only if ambiguous.
- Strip emoji and reactions from the original message when extracting.
Transformations:
| Slack message (excerpt) | Title |
| ----------------------------------------------------------------------- | --------------------------------------------------- |
| "unet model dropdown doesnt display all available models..." | Unet dropdown missing selected model |
| "Dates are broken on Settings -> Secrets. Cloud Prod" | Settings → Secrets dates broken on cloud prod |
| "LTX-2: Audio to VIdeo template results in the "RuntimeError..." error" | LTX-2 Audio-to-Video template RuntimeError on cloud |
## `description`
Structure — see `linear-api.md` § "Description body template". Key rules:
- Lead with **Repro** numbered list. Extract from the message body; if no steps are given, write "Repro: [Slack message body quoted verbatim]" and flag for human in approval.
- Preserve the reporter's own words in the Repro section when they include "step 1 / step 2" markers.
- Collapse multi-paragraph asides into "Notes" at the end.
## `env`
Detect from message text using these terms:
| Text in message | Tag |
| -------------------------- | ---------------------- |
| `cloud prod`, `prod cloud` | `cloud prod` |
| `cloud dev` | `cloud dev` |
| `cloud` | `cloud` (unqual.) |
| `local`, `localhost` | `local` |
| `electron`, `desktop` | `electron` |
| `nodes 2.0`, `LG` | (feature tag, not env) |
A message can have multiple env tags. If none are detectable, set `env: []` and flag "env unclear" in the approval row.
## `severity`
Heuristics in SKILL.md. When uncertain, mark `medium` and note in approval table: `Sev: medium (flag)`.
## `area`
Single tag. Use the one that best fits; tiebreak toward the more actionable team:
- `cloud` > `workflow` when the reported behavior is specific to cloud-hosted features (billing, queue, jobs)
- `node-system` > `ui` when the defect is canvas interaction, not just visual
- `templates` only when a named template is the subject
## `attachments`
From `slack_read_channel` message `Files:` field. Parse name, ID, type. Never include the Slack file ID in the Linear description — those are permissioned — just the filename and type.
## `thread_resolution`
Fetch via `slack_read_thread`. Scan replies for:
- `solved`, `resolved`, `fixed`, `no action needed``solved`
- A `:done:` reaction from the reporter → `solved`
- A `https://github.com/Comfy-Org/ComfyUI_frontend/pull/` URL in a reply → `pr-open` (keep but note in description)
- Otherwise → `open`
If `solved` and no PR merged, flag in approval table: reporter marked solved — confirm before filing.

View File

@@ -0,0 +1,99 @@
# Verify Commands Cookbook
One-shot commands for each False-Defect Verification class. Keep each under ~30s.
## 1. Check for existing fix PR
```bash
# By keyword in title
gh search prs --repo Comfy-Org/ComfyUI_frontend "<keyword>" --state merged --limit 5
# By keyword in body
gh pr list --repo Comfy-Org/ComfyUI_frontend --search "<keyword>" --state all --limit 5
# Recent closing PRs near the reported date
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
--search "merged:>=<YYYY-MM-DD> <keyword>" --limit 10
```
Verify tag: `fixed` if a merged PR explicitly matches; `pr-open` if an open PR matches.
## 2. Check for existing open Linear issue
```text
# Primary: @Linear search in the candidate's bug-dump thread
# mcp__plugin_slack_slack__slack_send_message({
# channel_id: "C0A4XMHANP3",
# thread_ts: "<parent-ts>",
# text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
# })
# → poll slack_read_thread, parse the Linear app's reply card for FE-NNNN matches.
#
# Fallback: grep past @Linear bot replies in the channel for prior ingested titles
# mcp__plugin_slack_slack__slack_search_public({
# query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
# })
```
Verify tag: `dedupe` with the `FE-NNNN` identifier in the approval row. See `reference/linear-api.md` § "Search existing open issues (dedupe)" for full handling.
## 3. Feature actually exists in codebase
```bash
# Find the component / feature mentioned
rg -l "<ComponentOrFeatureName>" src/ apps/ --type vue --type ts
# Find a setting key
rg "<setting-key>" src/locales/en/ src/stores/settingStore.ts
# Find a store action
rg "<actionName>" src/stores/ --type ts
```
Verify tag: `stale` if 0 hits AND the feature name is specific (not a generic word).
## 4. Intended behavior check
```bash
# Check docs and release notes
rg -l "<feature keyword>" docs/ CHANGELOG.md
# Check if behavior is asserted in an existing test (green today)
rg "<observed behavior>" src/**/*.test.ts browser_tests/
```
Verify tag: `expected` if docs describe this as the intended behavior, or a test asserts it.
## 5. Reporter self-resolution
Already gathered via `slack_read_thread`. Look for reporter's own replies containing:
- "solved", "resolved", "fixed", "no action needed", "nvm", "my bad"
- A `:done:` reaction from the reporter
- A `:white_check_mark:` reaction
Verify tag: `resolved`.
## 6. Env-specific / local setup
If the message mentions "my machine", "my proxy", "my docker", "my cache" AND no other reporter has confirmed in-thread:
```bash
# Check thread for cross-user confirmations
# slack_read_thread → count distinct users replying with "same", "repro'd", "+1"
```
Verify tag: `env` if only the reporter is affected.
## 7. Cross-post (X posting)
If the top-level message is just a link + "X posting":
```bash
# Follow the link — use slack_search_public to find the original thread
# slack_search_public({ query: "<in:channel from:@reporter> <before:date>" })
```
If the original is already ingestable, ingest from the original's permalink. If it's a GitHub issue, prefer linking that GitHub issue to the Linear ticket instead of creating two entries.
Verify tag: `cross-post` with the resolved source permalink.

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -0,0 +1,156 @@
---
name: reviewing-unit-tests
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
---
# Reviewing Unit Tests for ComfyUI_frontend
## Overview
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
## Review Workflow
1. Identify the test type: component, store, composable, util, or bugfix regression.
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
3. Open the authoritative doc section before judging structure.
4. Scan the red flags below.
5. State the verdict first. Name the failure mode. Cite the doc or rule.
## Source of Truth / Precedence
When docs and examples conflict, use this order:
1. Explicit repo rules, lint rules, and note blocks.
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
4. Example snippets
5. Prior diffs
Apply these repo-specific clarifications:
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
- Motion != fix.
## 30-Second Red Flags
| If you see... | Failure mode | Default action |
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
## Rationalization Table
| Excuse | Reality |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
| "It is only a nit" | Explicit repo-rule violations are never nits. |
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
## Mocking Rules
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
### Alias-by-Renaming
```ts
// Before
const mockAdd = vi.hoisted(() => vi.fn())
// After: same indirection, new name
function getToastAddMock() {
return useToast().add
}
```
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
### `vi.mocked()` Scope
| Use case | `vi.mocked()` required? |
| --------------------------------------------------------------- | ----------------------- |
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
| `.mock.calls`, `.mock.results` | Yes |
| `expect(fn).toHaveBeenCalled()` | No |
| `expect(fn).toHaveBeenCalledWith(...)` | No |
- Flag casts whenever `vi.mocked()` would narrow correctly.
- Do not add `vi.mocked()` around assertion-only references just for style.
### Reset Hygiene
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
### Third-Party Seams
- Distinguish trivial hooks from behavior-rich APIs.
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
- That exception does not justify mocking behavior-rich third-party modules.
### `vue-i18n`
- Never mock `vue-i18n` in component tests.
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
## Test-Body Rules
| Smell | Review bar |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Change-detector test | Reject. Default values alone prove nothing. |
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
## Bugfix Regression Proof
For `fix:` PRs or bugfix diffs:
1. Identify the production change that fixes the bug.
2. Verify the new test fails on pre-fix code, or ask the author to show it.
3. If the test passes on broken code, request changes.
A regression test that never proves red does not pin the bug.
## Review Output Rules
- State verdict before procedural questions.
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
- Link the authoritative doc section in the review comment.
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
## Quick Reference
| When you see... | Read this |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
## Key Files to Read
| Purpose | Path |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |

View File

@@ -46,3 +46,9 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

23
.github/actions/ashby-pull/action.yaml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Ashby Pull
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
inputs:
api_key:
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
required: true
job_board_name:
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh Ashby snapshot
shell: bash
env:
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot

View File

@@ -0,0 +1,87 @@
# Outputs default to 'true' for non-pull_request events (push, merge_group):
# granular path filtering is a PR-only optimization. This avoids the silent
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
# would never run on push.
#
# Shared dependency files (root package.json, pnpm-lock.yaml,
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
# output so a lockfile bump correctly invalidates each granular gate. They
# are NOT folded into docs-changes.
#
# Two paths-filter steps are needed because predicate-quantifier=every is
# required for the negated globs in `should-run` but breaks multi-pattern
# OR filters like `docs:` and `deps:`.
#
# Requires the caller to have checked out the repository.
name: 'Detect Path Changes'
description: >
Computes typed *-changes outputs and a back-compat should-run for
path-gated CI jobs.
outputs:
should-run:
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
app-website-changes:
description: 'Shared deps or `apps/website/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
app-desktop-changes:
description: 'Shared deps or `apps/desktop-ui/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
app-frontend-changes:
description: 'Shared deps or `src/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
packages-changes:
description: 'Shared deps or `packages/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
storybook-changes:
description: 'Shared deps or `.storybook/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
docs-changes:
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
dependency-changes:
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
runs:
using: composite
steps:
- name: Filter typed changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
app_website:
- 'apps/website/**'
app_desktop:
- 'apps/desktop-ui/**'
app_frontend:
- 'src/**'
packages:
- 'packages/**'
storybook:
- '.storybook/**'
docs:
- 'docs/**'
- '**/*.md'
deps:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Filter relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: relevant
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
relevant:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'

View File

@@ -0,0 +1,79 @@
name: Upsert Comment Section
description: >
Manage a consolidated PR comment with independently-updatable sections.
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
Valid section names: "e2e", "preview", "screenshot-update".
inputs:
pr-number:
description: PR number to comment on
required: true
section-name:
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
required: true
section-content:
description: Markdown content for this section
required: true
comment-marker:
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
required: true
token:
description: GitHub token with pull-requests write permission
required: true
runs:
using: composite
steps:
- uses: actions/github-script@v8
env:
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
INPUT_SECTION_NAME: ${{ inputs.section-name }}
INPUT_SECTION_CONTENT: ${{ inputs.section-content }}
INPUT_COMMENT_MARKER: ${{ inputs.comment-marker }}
with:
github-token: ${{ inputs.token }}
script: |
const prNumber = Number(process.env.INPUT_PR_NUMBER)
const sectionName = process.env.INPUT_SECTION_NAME
const sectionContent = process.env.INPUT_SECTION_CONTENT
const commentMarker = process.env.INPUT_COMMENT_MARKER
const sectionStart = `<!-- section:${sectionName}:start -->`
const sectionEnd = `<!-- section:${sectionName}:end -->`
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
// Escape special regex characters in delimiter strings
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: prNumber }
)
const existing = comments.find(
(c) =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes(commentMarker)
)
if (!existing) {
return github.rest.issues.createComment({
...context.repo,
issue_number: prNumber,
body: `${commentMarker}\n${sectionBlock}`
})
}
const body = existing.body ?? ''
const sectionRegex = new RegExp(
`${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
)
const updated = sectionRegex.test(body)
? body.replace(sectionRegex, sectionBlock)
: body.trimEnd() + '\n\n' + sectionBlock
return github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body: updated
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -12,17 +12,30 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
scan:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -14,16 +14,29 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
validate-fonts:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -68,15 +81,17 @@ jobs:
echo '✅ No proprietary fonts found in dist'
validate-licenses:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -3,10 +3,8 @@ name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
@@ -16,12 +14,24 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,9 +16,22 @@ permissions:
contents: read
jobs:
collect:
changes:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
collect:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

View File

@@ -58,8 +58,8 @@ jobs:
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)

View File

@@ -51,15 +51,16 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
uses: actions/download-artifact@v7
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -4,10 +4,8 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
workflow_dispatch:
@@ -16,7 +14,20 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
setup:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -54,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -112,7 +123,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -164,9 +175,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [playwright-tests-chromium-sharded]
needs: [changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -195,14 +206,38 @@ jobs:
path: ./playwright-report/
retention-days: 30
# Gate job — single required check that passes whether the matrix ran or was
# skipped. Branch rulesets require this instead of the individual matrix-
# expanded check names so PRs with no e2e-relevant changes aren't stuck.
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: >-
${{
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
steps:
@@ -221,9 +256,15 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
needs: [changes, playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: >-
${{
always() &&
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
contents: read

View File

@@ -8,10 +8,29 @@ on:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
permissions:
pull-requests: write
steps:
@@ -30,8 +49,13 @@ jobs:
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
if: |
github.event_name == 'pull_request'
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -67,8 +91,15 @@ jobs:
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -107,9 +138,15 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [storybook-build]
needs: [changes, storybook-build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
if: |
always()
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
permissions:
pull-requests: write
contents: read

View File

@@ -4,10 +4,8 @@ name: 'CI: Tests Unit'
on:
push:
branches: [main, master, dev*, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
concurrency:
@@ -15,7 +13,20 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
test:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:

View File

@@ -52,6 +52,9 @@ jobs:
run: vercel pull --yes --environment=preview
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: vercel build
- name: Fetch head commit metadata
@@ -146,6 +149,9 @@ jobs:
run: vercel pull --yes --environment=production
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -4,23 +4,29 @@ name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
build:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -30,4 +36,7 @@ jobs:
uses: ./.github/actions/setup-frontend
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build

View File

@@ -2,60 +2,46 @@ name: 'CI: Website E2E'
on:
push:
branches: [main, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
branches: [main]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
website-e2e:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
pull-requests: write
contents: read
outputs:
test-outcome: ${{ steps.tests.outcome }}
report-url: ${{ steps.deploy.outputs.url }}
screenshot-failures: ${{ steps.failures.outputs.screenshot }}
other-failures: ${{ steps.failures.outputs.other }}
# Evaluated at job level (not from a step) — static expression.
is-pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
steps:
- uses: actions/checkout@v6
- name: Post starting comment
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- WEBSITE_E2E_STATUS -->'
const body = [
marker,
'## 🌐 Website E2E',
'',
'> [!NOTE]',
`> Tests are running… [View workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
].join('\n')
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number
})
const existing = comments.find(c => c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
}
- name: Install pnpm
run: corepack enable && corepack prepare
@@ -63,6 +49,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
@@ -134,15 +122,20 @@ jobs:
core.setOutput('screenshot', screenshotFailures)
core.setOutput('other', failed.length - screenshotFailures)
- name: Post result comment and summary
- name: Write job summary
if: always() && !cancelled()
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ steps.tests.outcome }}
REPORT_URL: ${{ steps.deploy.outputs.url }}
SCREENSHOT_FAILURES: ${{ steps.failures.outputs.screenshot }}
OTHER_FAILURES: ${{ steps.failures.outputs.other }}
with:
script: |
const passed = '${{ steps.tests.outcome }}' === 'success'
const reportUrl = '${{ steps.deploy.outputs.url }}'
const screenshotFailures = parseInt('${{ steps.failures.outputs.screenshot }}') || 0
const otherFailures = parseInt('${{ steps.failures.outputs.other }}') || 0
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '']
@@ -152,7 +145,91 @@ jobs:
lines.push('> [!CAUTION]', '> Some tests failed.')
}
// Results table
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
await core.summary.addRaw(lines.join('\n')).write()
post-starting-comment:
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
needs: changes
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🌐 Website E2E
<!-- WEBSITE_E2E_STATUS -->
> [!NOTE]
> Tests are running… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: website-e2e
if: always() && !cancelled() && needs.website-e2e.outputs.is-pr == 'true'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build e2e section content
id: content
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ needs.website-e2e.outputs.test-outcome }}
REPORT_URL: ${{ needs.website-e2e.outputs.report-url }}
SCREENSHOT_FAILURES: ${{ needs.website-e2e.outputs.screenshot-failures }}
OTHER_FAILURES: ${{ needs.website-e2e.outputs.other-failures }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '<!-- WEBSITE_E2E_STATUS -->', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
@@ -182,20 +259,12 @@ jobs:
)
}
const summary = lines.join('\n')
await core.summary.addRaw(summary).write()
core.setOutput('section-content', lines.join('\n'))
if (context.eventName === 'pull_request' && !context.payload.pull_request.head.repo.fork) {
const marker = '<!-- WEBSITE_E2E_STATUS -->'
const body = `${marker}\n${summary}`
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number
})
const existing = comments.find(c => c.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
}
}
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

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

View File

@@ -8,7 +8,7 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
update-screenshots:
@@ -16,7 +16,12 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
@@ -34,6 +39,11 @@ jobs:
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
outputs:
pr-number: ${{ steps.pr-info.outputs.pr-number }}
update-outcome: ${{ steps.update-screenshots.outcome }}
has-changes: ${{ steps.commit.outputs.has-changes }}
changed-count: ${{ steps.commit.outputs.changed-count }}
steps:
- name: Verify sender permissions
if: >
@@ -53,9 +63,11 @@ jobs:
- name: Get PR info
id: pr-info
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ github.event.number || github.event.issue.number }}
with:
script: |
const prNumber = ${{ github.event.number || github.event.issue.number }}
const prNumber = Number(process.env.PR_NUMBER)
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: prNumber
@@ -75,6 +87,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
@@ -90,7 +104,10 @@ jobs:
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/)" ]; then
CHANGED=$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/ | wc -l)
echo "changed-count=${CHANGED}" >> $GITHUB_OUTPUT
if [ "$CHANGED" -eq 0 ]; then
echo "No screenshot changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
@@ -112,14 +129,117 @@ jobs:
- name: Remove label
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
with:
script: |
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: ${{ steps.pr-info.outputs.pr-number }},
issue_number: Number(process.env.PR_NUMBER),
name: 'Update Website Screenshots'
})
} catch (e) {
// Label may already be removed
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
}
post-starting-comment:
# Runs in parallel with update-screenshots to show "in progress" immediately.
# ⚠️ This condition is duplicated from `update-screenshots` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.number || github.event.issue.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.number || github.event.issue.number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 📸 Screenshot Update
> [!NOTE]
> Updating screenshots… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: update-screenshots
if: always() && !cancelled() && needs.update-screenshots.result != 'skipped'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ needs.update-screenshots.outputs.pr-number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build screenshot-update section content
id: content
uses: actions/github-script@v8
env:
UPDATE_OUTCOME: ${{ needs.update-screenshots.outputs.update-outcome }}
HAS_CHANGES: ${{ needs.update-screenshots.outputs.has-changes }}
CHANGED_COUNT: ${{ needs.update-screenshots.outputs.changed-count }}
with:
script: |
const outcome = process.env.UPDATE_OUTCOME
const hasChanges = process.env.HAS_CHANGES === 'true'
const changedCount = parseInt(process.env.CHANGED_COUNT) || 0
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const lines = ['## 📸 Screenshot Update', '']
if (outcome !== 'success') {
lines.push(
'> [!CAUTION]',
`> Screenshot update failed. [View workflow run](${runUrl})`
)
} else if (!hasChanges) {
lines.push(
'> [!TIP]',
'> All screenshots are already up to date.'
)
} else {
const s = changedCount === 1 ? '' : 's'
lines.push(
'> [!TIP]',
`> Updated ${changedCount} screenshot${s} and pushed to the branch.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -7,11 +7,6 @@ on:
types:
- completed
permissions:
contents: read
pull-requests: write
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
@@ -19,6 +14,16 @@ concurrency:
jobs:
comment:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: read
# Uses head_branch as proxy for PR number (unavailable at job-level in workflow_run).
# Preview and E2E comment writes are NOT mutually serialized — the race window is
# small and self-healing on next push.
concurrency:
group: website-pr-comment-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
@@ -37,28 +42,29 @@ jobs:
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Write report
- name: Read preview URLs
if: steps.pr-meta.outputs.skip != 'true'
env:
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
id: urls
run: |
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
SHORT_SHA="${HEAD_SHA:0:7}"
cat > preview-report.md <<EOF
**Website Preview:** $STABLE_URL
echo "stable-url=$(cat temp/vercel-preview/stable-url.txt)" >> "$GITHUB_OUTPUT"
echo "unique-url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
echo "short-sha=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT"
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
<sub>This commit: $UNIQUE_URL</sub>
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
EOF
- name: Post PR comment
- name: Post preview comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./preview-report.md
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
section-name: preview
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🔗 Website Preview
**Website Preview:** ${{ steps.urls.outputs.stable-url }}
<sub>This commit: ${{ steps.urls.outputs.unique-url }}</sub>
<sub>Last updated: ${{ github.event.workflow_run.updated_at }} for `${{ steps.urls.outputs.short-sha }}`</sub>

59
.github/workflows/release-website.yaml vendored Normal file
View File

@@ -0,0 +1,59 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
workflow_dispatch:
concurrency:
group: release-website
cancel-in-progress: true
jobs:
refresh-snapshot:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
- name: Refresh Ashby snapshot
uses: ./.github/actions/ashby-pull
with:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
base: main
labels: |
Release:Website
delete-branch: true

View File

@@ -1,5 +1,13 @@
#!/usr/bin/env bash
# Skip in CI: the canonical knip check runs in ci-lint-format on every
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
# setup-comfyui-server, which contaminates knip's project glob with the
# devtools copy under custom_nodes and produces false-positive failures.
if [ -n "${CI-}" ]; then
exit 0
fi
# Run Knip with cache via package script
pnpm knip 1>&2

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-red-600);
background-color: var(--color-coral-700);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
background-color: var(--color-coral-600);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
background-color: var(--color-coral-500);
}
.task-div .p-card {

View File

@@ -2,6 +2,7 @@ dist/
.astro/
test-results/
playwright-report/
results.json
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

148
apps/website/README.md Normal file
View File

@@ -0,0 +1,148 @@
# @comfyorg/website
Marketing/brand website built with Astro + Vue.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
API at build time. Data flow:
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
Astro build.
2. `src/utils/ashby.ts` calls
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
validates the envelope and each posting with Zod
(`src/utils/ashby.schema.ts`), and maps to the domain type in
`src/data/roles.ts`.
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
`src/data/ashby-roles.snapshot.json`.
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
builds.
### Required environment variables
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
| ------------------------------ | --------------------------- | --------------------------------- |
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
### CI wiring (manual step — required)
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits **once**:
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
build step and run the unit tests before it:
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
```
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
two env vars to the top-level `env:` block so `vercel build` (both
`deploy-preview` and `deploy-production` jobs) sees them:
```yaml
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
```
The secret must also be added to the Vercel project environment
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
that `vercel build` in the preview job has access to it.
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by `pr-vercel-website-preview.yaml`.
### Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after
onboarding/offboarding roles):
```bash
WEBSITE_ASHBY_API_KEY=WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
```html
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
```
The localized `/zh-CN/contact` page uses the same portal and script with form
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
renders the documented embed container.
## Scripts
- `pnpm dev` — Astro dev server
- `pnpm build` — production build to `dist/`
- `pnpm typecheck``astro check`
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot

View File

@@ -6,6 +6,16 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
vite: {

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Careers page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/careers')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers — Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Roles', level: 2 })
).toBeVisible()
})
test('renders at least one role from the snapshot', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
await expect(roles.first()).toBeVisible()
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 — Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
})
})

View File

@@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -122,17 +122,6 @@ test.describe('About', { tag: '@visual' }, () => {
}
})
const OVERFLOW_SKIP = new Set([
'/ 3-lg',
'/ 4-xl',
'/cloud 2-md',
'/cloud 3-lg',
'/cloud 4-xl',
'/download 2-md',
'/download 3-lg',
'/download 4-xl'
])
test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
@@ -146,10 +135,7 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
]
for (const url of pages) {
for (const vp of VIEWPORTS) {
const key = `${url} ${vp.name}`
test(`${url} ${vp.name} no overflow`, async ({ page }) => {
test.skip(OVERFLOW_SKIP.has(key), 'Known overflow bug at this viewport')
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto(url)
await assertNoOverflow(page)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 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: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 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: 98 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -9,10 +9,13 @@
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots"
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
@@ -23,7 +26,9 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:"
"posthog-js": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
@@ -32,7 +37,9 @@
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
@@ -89,6 +96,22 @@
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [

View File

@@ -0,0 +1,58 @@
# Comfy
> Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Built around ComfyUI — the open-source node-graph runtime with 60,000+ community nodes and thousands of shared workflows — Comfy ships as a free local app, a managed cloud, an API, and an enterprise platform.
The Comfy ecosystem spans four surfaces:
- **ComfyUI (local)** — the open-source node-graph runtime that runs models on your own hardware.
- **Comfy Cloud** — managed ComfyUI in the browser, with hosted models and storage.
- **Comfy API** — a REST API for triggering workflows from your own apps and pipelines.
- **Comfy Enterprise** — single-tenant deployments, BYO keys, data ownership, and orchestration for teams.
Studios building with Comfy include Series Entertainment, Moment Factory, Open Story Movement, and Ubisoft (La Forge). Use cases concentrate in VFX & animation, advertising & creative studios, gaming, and eCommerce/fashion.
## Product
- [Homepage](https://comfy.org/): Overview of Comfy and the four product surfaces (Local, Cloud, API, Enterprise).
- [Download Comfy (Local)](https://comfy.org/download/): Free desktop app for macOS, Windows, and Linux — runs ComfyUI on your own GPU.
- [Comfy Cloud](https://comfy.org/cloud/): Managed ComfyUI in the browser with hosted models and storage; no local install required.
- [Comfy Cloud Pricing](https://comfy.org/cloud/pricing/): Plans and per-credit pricing for individuals and teams using Comfy Cloud.
- [Comfy API](https://comfy.org/api/): REST API for triggering ComfyUI workflows programmatically from external apps.
- [Comfy Enterprise](https://comfy.org/cloud/enterprise/): Single-tenant ComfyUI deployments with BYO keys, orchestration, and data-ownership guarantees.
## Workflows and Gallery
- [Workflow Gallery](https://comfy.org/gallery/): Curated showcase of ComfyUI outputs — images, video, and 3D — produced by the community.
- [Community Workflows](https://www.comfy.org/workflows/): Browseable library of community-shared ComfyUI workflows you can load and remix.
## Customers and Case Studies
- [Customer Stories](https://comfy.org/customers/): Index of named customers and how they use ComfyUI in production.
- [Series Entertainment](https://comfy.org/customers/series-entertainment/): How Series Entertainment rebuilt game and video production around ComfyUI.
- [Moment Factory](https://comfy.org/customers/moment-factory/): Architectural-scale 3D projection mapping reimagined with ComfyUI at Moment Factory.
- [Ubisoft — Chord](https://comfy.org/customers/ubisoft-chord/): Ubisoft La Forge open-sourcing the Chord model and its ComfyUI integration.
- [Open Story Movement](https://comfy.org/customers/open-story-movement/): How an open-source movement around AI storytelling builds on ComfyUI.
## Developers and Documentation
- [ComfyUI Docs](https://docs.comfy.org/): Official documentation for installing, configuring, and extending ComfyUI.
- [ComfyUI on GitHub](https://github.com/comfyanonymous/ComfyUI): Source repository for the open-source ComfyUI runtime.
- [Comfy-Org on GitHub](https://github.com/Comfy-Org): Organization-wide repositories — frontend, registry, manager, docs, and tooling.
- [Comfy Registry](https://registry.comfy.org/): Public registry of ComfyUI custom nodes and extensions, with versioning and search.
## Company
- [About Comfy](https://comfy.org/about/): Company background, mission, and the team behind ComfyUI.
- [Careers](https://comfy.org/careers/): Open roles across engineering, design, product, and go-to-market.
- [Contact](https://comfy.org/contact/): Sales, partnership, and general contact form.
- [Blog](https://blog.comfy.org/): Product announcements, technical deep-dives, and customer stories.
- [Privacy Policy](https://comfy.org/privacy-policy/): How Comfy collects, uses, and protects personal information.
- [Terms of Service](https://comfy.org/terms-of-service/): Terms governing use of ComfyUI and related Comfy services.
## Optional
- [简体中文 / Chinese homepage](https://comfy.org/zh-CN/): Simplified Chinese localization of the main site.
- [Series Entertainment — long-form case study](https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui): Extended write-up of the Series Entertainment deployment.
- [Moment Factory — long-form case study](https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping): Extended write-up of Moment Factory's projection-mapping pipeline.
- [Ubisoft Chord announcement (blog)](https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model): Original blog post announcing Ubisoft's open-source Chord model.
- [Open-source storytelling (blog)](https://blog.comfy.org/p/how-open-source-is-fueling-the-open): Blog post on how open source is fueling the Open Story Movement.

View File

@@ -1,4 +1,33 @@
# robots.txt for comfy.org
# Open to all crawlers — including AI/LLM bots — for maximum visibility
# in AI-powered search, chat-based answer engines, and traditional search.
# Granular UAs are listed explicitly to signal intent; rules are shared
# via stacked user-agent records (RFC 9309 §2.2).
User-agent: *
User-agent: Googlebot
User-agent: Bingbot
User-agent: DuckDuckBot
User-agent: GPTBot
User-agent: ChatGPT-User
User-agent: OAI-SearchBot
User-agent: Google-Extended
User-agent: ClaudeBot
User-agent: Claude-Web
User-agent: anthropic-ai
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: Applebot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: Amazonbot
User-agent: CCBot
User-agent: Meta-ExternalAgent
User-agent: Meta-ExternalFetcher
User-agent: Diffbot
Allow: /
Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<g id="title404" fill="currentColor" transform="matrix(0.9346 -0.02737 -0.00901 1.0422 -597.31 -135.88)">
<path d="M1436.44 522.556V439.749L1462.05 454.536C1478.8 464.21 1489.92 479.348 1489.92 505.313V518.746C1489.92 544.611 1478.72 546.966 1462.05 537.342L1436.44 522.556ZM1442.77 519.899L1461.87 530.926C1477.15 539.748 1483.32 535.087 1483.32 515.137V501.303C1483.32 481.253 1477.15 469.573 1461.87 460.751L1442.77 449.724V519.899Z"/>
<path d="M1369.77 484.067V401.261L1376.02 404.87L1416.65 498.303V428.328L1422.73 431.837V514.643L1416.48 511.034L1375.85 417.601V487.576L1369.77 484.067Z"/>
<path d="M1330.66 462.491C1311.56 451.464 1304.62 438.231 1304.62 417.68V363.645L1311.04 367.354V421.69C1311.04 440.737 1318.34 448.857 1330.66 455.975C1342.99 463.093 1350.2 463.343 1350.2 444.296V389.96L1356.62 393.67V447.704C1356.62 468.757 1349.24 473.218 1330.66 462.491Z"/>
<path d="M1265.37 424.794C1245.75 413.466 1236.11 392.263 1236.11 372.614V358.278C1236.11 338.83 1245.75 328.654 1265.37 339.983C1285.08 351.361 1294.72 372.664 1294.72 392.113V406.448C1294.72 426.097 1285.08 436.173 1265.37 424.794ZM1242.71 376.724C1242.71 394.068 1250.53 409.506 1265.37 418.077C1280.3 426.699 1288.12 420.283 1288.12 402.94V388.002C1288.12 370.96 1280.3 355.321 1265.37 346.699C1250.53 338.128 1242.71 344.745 1242.71 361.787V376.724Z"/>
<path d="M1187.24 378.685V295.879L1231.78 321.593V328.009L1193.58 305.954V341.643L1229.79 362.545V368.961L1193.58 348.059V382.344L1187.24 378.685Z"/>
<path d="M1129.17 345.153V268.763L1106.33 255.58V249.164L1158.34 279.189V285.605L1135.5 272.422V348.812L1129.17 345.153Z"/>
<path d="M1076.63 315.825C1057.01 304.497 1047.37 283.294 1047.37 263.645V249.309C1047.37 229.861 1057.01 219.685 1076.63 231.014C1096.34 242.392 1105.98 263.695 1105.98 283.144V297.479C1105.98 317.128 1096.34 327.204 1076.63 315.825ZM1053.97 267.755C1053.97 285.099 1061.79 300.537 1076.63 309.108C1091.56 317.73 1099.38 311.314 1099.38 293.971V279.033C1099.38 261.991 1091.56 246.352 1076.63 237.73C1061.79 229.159 1053.97 235.776 1053.97 252.818V267.755Z"/>
<path d="M983.922 261.297V178.49L990.173 182.099L1030.8 275.532V205.558L1036.88 209.066V291.873L1030.63 288.264L989.999 194.831V264.805L983.922 261.297Z"/>
<path d="M905.279 215.892V133.086L949.557 158.65V165.066L911.617 143.161V174.94L947.994 195.943V202.359L911.617 181.356V213.135L949.557 235.04V241.456L905.279 215.892Z"/>
<path d="M864.552 193.381C846.059 182.704 836.422 161.702 836.422 141.852V127.517C836.422 107.767 845.972 97.8425 865.507 109.121C883.131 119.296 892.855 137.642 893.723 154.083L893.81 158.243L887.038 154.333V149.822C886.343 135.687 878.356 123.156 865.68 115.837C850.313 106.965 842.934 113.832 842.934 130.875V145.912C842.934 163.356 850.4 178.594 865.073 187.065C878.096 194.584 886.864 191.225 887.125 175.436V170.123L860.732 154.885V148.669L893.81 167.767V209.27L889.208 206.614L888.601 191.426C884.52 198.193 876.706 200.398 864.552 193.381Z"/>
<path d="M777.479 142.107L801.615 73.2351L809.515 77.7965L833.738 174.588L826.966 170.678L821.93 150.027L789.286 131.18L784.251 146.017L777.479 142.107ZM791.11 125.816L820.107 142.558L805.869 84.2124L805.522 83.0094L805.261 83.8616L791.11 125.816Z"/>
<path d="M727.161 113.056V30.2494L756.853 47.3921C772.047 56.164 779.513 69.2967 779.513 86.9407V88.1437C779.513 105.687 772.047 110.6 756.853 101.828L733.499 88.3442V116.715L727.161 113.056ZM733.499 82.0285L756.419 95.2615C767.792 101.828 772.915 98.67 772.915 84.7352V82.63C772.915 68.7955 767.792 60.0236 756.419 53.4572L733.499 40.2242V82.0285Z"/>
<path d="M582.85 5.5V67.98L592.376 62.48V72.38L582.85 77.88V96.36L572.942 102.08V83.6L532.075 107.195V96.855L573.133 11.11L582.85 5.5ZM541.601 91.795L572.942 73.7V27.61L541.601 91.795Z"/>
<path d="M495.102 148.121C475.096 159.671 464.903 150.816 464.903 126.286V111.546C464.903 87.2364 475.192 66.5564 495.102 55.0614C514.631 43.7864 524.824 52.6414 524.824 76.9514V91.6914C524.824 116.221 514.631 136.846 495.102 148.121ZM475.001 121.556C475.001 127.936 475.763 132.666 477.287 136.076L509.296 63.2564C506.057 60.8364 501.294 61.3864 495.102 64.9614C481.765 72.6614 475.001 86.0264 475.001 104.726V121.556ZM480.812 140.311C484.146 142.456 488.91 141.796 495.102 138.221C508.057 130.741 514.726 117.431 514.726 98.5114V81.6814C514.726 75.7414 514.059 71.1764 512.725 67.8764L480.812 140.311Z"/>
<path d="M447.443 83.6773V146.157L456.969 140.657V150.557L447.443 156.057V174.537L437.535 180.257V161.777L396.668 185.372V175.032L437.726 89.2873L447.443 83.6773ZM406.194 169.972L437.535 151.877V105.787L406.194 169.972Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,83 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -0,0 +1,33 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchRolesForBuild } from '../src/utils/ashby'
const snapshotPath = fileURLToPath(
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchRolesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
const totalRoles = outcome.snapshot.departments.reduce(
(n, d) => n + d.roles.length,
0
)
process.stdout.write(
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
)

View File

@@ -0,0 +1,51 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const investors = [
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.story.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.story.headingBefore', locale)
}}<span class="text-primary-comfy-yellow">{{
t('about.story.headingHighlight', locale)
}}</span
>{{ t('about.story.headingAfter', locale) }}
</h2>
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
{{ t('about.story.body', locale) }}
</p>
</div>
<!-- Investor card -->
<div
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
>
<div class="inline-flex items-center">
<!-- OUR badge (shorter) -->
<div class="relative z-10 flex h-9 items-center">
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
>
OUR
</span>
</div>
<!-- Union connector (overlaps both badges to eliminate seams) -->
<img
src="/icons/node-union-2size-reverse.svg"
alt=""
class="relative z-20 -mx-px h-12 w-auto"
/>
<!-- INVESTORS badge (taller) -->
<div class="relative z-10 flex h-12 items-center">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
>
INVESTORS
</span>
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
</div>
</div>
<p
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
>
{{ t('about.story.investorsBody', locale) }}
</p>
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
<div
v-for="investor in investors"
:key="investor.name"
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
>
<img
:src="investor.icon"
:alt="investor.name"
class="max-h-8 w-auto"
/>
</div>
</div>
</div>
<!-- Quote card -->
<div
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
>
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
{{ t('about.quote.text', locale) }}
</p>
<p
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
>
{{ t('about.quote.attribution', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -1,121 +1,42 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { locale = 'en', departments = [] } = defineProps<{
locale?: Locale
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
interface Role {
title: string
department: string
location: string
id: string
}
interface Department {
name: string
key: string
roles: Role[]
}
const departments: Department[] = [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
id: 'abc787b9-ad85-421c-8218-debd23bea096'
},
{
title: 'Software Engineer',
department: 'Engineering',
location: 'San Francisco',
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
},
{
title: 'Product Manager',
department: 'Engineering',
location: 'London, UK',
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
},
{
title: 'Tech Lead Manager, Frontend',
department: 'Engineering',
location: 'San Francisco',
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
}
]
},
{
name: 'DESIGN',
key: 'design',
roles: [
{
title: 'Creative Director',
department: 'Design',
location: 'San Francisco',
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
},
{
title: 'Graphic Designer',
department: 'Design',
location: 'London, UK',
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
},
{
title: 'Freelance Motion Designer',
department: 'Design',
location: 'Remote',
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
}
]
},
{
name: 'MARKETING',
key: 'marketing',
roles: [
{
title: 'Lifecycle Growth Marketer',
department: 'Marketing',
location: 'San Francisco',
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
},
{
title: 'Graphic Designer',
department: 'Marketing',
location: 'London, UK',
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
}
]
}
]
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...departments.map((d) => ({ label: d.name, value: d.key }))
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? departments
: departments.filter((d) => d.key === activeCategory.value)
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
</script>
<template>
<section class="px-6 py-20 md:px-20 md:py-32">
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<!-- Left sidebar -->
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
@@ -126,6 +47,7 @@ const filteredDepartments = computed(() =>
{{ t('careers.roles.heading', locale) }}
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
class="mt-4"
@@ -133,8 +55,15 @@ const filteredDepartments = computed(() =>
</div>
</div>
<!-- Role listings -->
<div class="min-w-0 flex-1">
<p
v-if="!hasRoles"
class="text-primary-warm-gray text-base md:text-lg"
data-testid="careers-roles-empty"
>
{{ t('careers.roles.empty', locale) }}
</p>
<div
v-for="dept in filteredDepartments"
:key="dept.key"
@@ -147,10 +76,11 @@ const filteredDepartments = computed(() =>
<a
v-for="role in dept.roles"
:key="role.id"
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
:href="role.applyUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div class="min-w-0">
<span

View File

@@ -5,8 +5,6 @@ const { playing = false, size = 'md' } = defineProps<{
playing?: boolean
size?: 'sm' | 'md'
}>()
defineEmits<{ toggle: [] }>()
</script>
<template>
@@ -18,7 +16,6 @@ defineEmits<{ toggle: [] }>()
size === 'sm' ? 'size-8 lg:size-10' : 'size-12'
)
"
@click="$emit('toggle')"
>
<svg
v-if="playing"

View File

@@ -204,13 +204,12 @@ onMounted(() => {
/>
</div>
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
<!-- Desktop CTA buttons -->
<div
data-testid="desktop-nav-cta"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
<BrandButton
v-for="cta in ctaButtons"
:key="cta.href"

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -201,6 +201,7 @@ function toggleFullscreen() {
playsinline
:autoplay
muted
@click="playing = !playing"
>
<track
v-for="track in tracks"
@@ -221,13 +222,14 @@ function toggleFullscreen() {
playing && !hovering && 'pointer-events-none opacity-0'
)
"
@click="playing = !playing"
>
<PlayPauseButton
:playing
:aria-label="
playing ? t('player.pause', locale) : t('player.play', locale)
"
@toggle="playing = !playing"
@click.stop="playing = !playing"
/>
</div>
@@ -248,7 +250,7 @@ function toggleFullscreen() {
:aria-label="
playing ? t('player.pause', locale) : t('player.play', locale)
"
@toggle="playing = !playing"
@click="playing = !playing"
/>
<!-- Progress scrubber -->

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import SectionLabel from '../common/SectionLabel.vue'
import HubspotFormEmbed from './HubspotFormEmbed.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
@@ -17,30 +16,6 @@ function tk(suffix: string): TranslationKey {
return `contact.form.${suffix}` as TranslationKey
}
const firstName = ref('')
const lastName = ref('')
const company = ref('')
const phone = ref('')
const selectedPackage = ref('')
const comfyUsage = ref('')
const lookingFor = ref('')
const packageOptions = [
'packageIndividual',
'packageTeams',
'packageEnterprise'
] as const
const usageOptions = [
'usingYesProduction',
'usingYesTesting',
'usingNotYet',
'usingOtherTools'
] as const
const inputClass =
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
const sectionRef = ref<HTMLElement>()
const badgeRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
@@ -55,10 +30,6 @@ useHeroAnimation({
video: formRef,
parallax: false
})
function handleSubmit() {
// TODO: implement form submission
}
</script>
<template>
@@ -105,160 +76,7 @@ function handleSubmit() {
<!-- Right column: form -->
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- First Name + Last Name -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('firstName'), locale) }}*
</label>
<input
v-model="firstName"
type="text"
required
:placeholder="t(tk('firstNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lastName'), locale) }}*
</label>
<input
v-model="lastName"
type="text"
required
:placeholder="t(tk('lastNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
</div>
<!-- Company + Phone -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('company'), locale) }}*
</label>
<input
v-model="company"
type="text"
required
:placeholder="t(tk('companyPlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('phone'), locale) }}
</label>
<input v-model="phone" type="tel" :class="inputClass" />
</div>
</div>
<!-- Package selection -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('packageQuestion'), locale) }}
</p>
<div class="mt-3 flex flex-wrap gap-3">
<label
v-for="opt in packageOptions"
:key="opt"
:class="
cn(
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
selectedPackage === opt
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
)
"
>
<input
v-model="selectedPackage"
type="radio"
name="package"
:value="opt"
class="sr-only"
/>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
selectedPackage === opt
? 'border-primary-comfy-yellow'
: 'border-primary-warm-gray/40'
)
"
>
<span
v-if="selectedPackage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
{{ t(tk(opt), locale) }}
</label>
</div>
</div>
<!-- Comfy usage -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('usingComfy'), locale) }}
</p>
<div class="mt-3 space-y-3">
<label
v-for="opt in usageOptions"
:key="opt"
class="flex cursor-pointer items-center gap-3"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
comfyUsage === opt
? 'border-primary-comfy-yellow'
: 'border-(--site-border-subtle)'
)
"
>
<span
v-if="comfyUsage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
<input
v-model="comfyUsage"
type="radio"
:value="opt"
class="sr-only"
/>
<span class="text-primary-comfy-canvas text-sm">
{{ t(tk(opt), locale) }}
</span>
</label>
</div>
</div>
<!-- Looking for -->
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lookingFor'), locale) }}
</label>
<textarea
v-model="lookingFor"
:placeholder="t(tk('lookingForPlaceholder'), locale)"
:class="cn(inputClass, 'min-h-24 resize-y')"
/>
</div>
<!-- Submit -->
<div>
<BrandButton type="submit" variant="outline" size="sm">
{{ t(tk('submit'), locale) }}
</BrandButton>
</div>
</form>
<HubspotFormEmbed :locale />
</div>
</section>
</template>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
const HUBSPOT_CONTACT_PORTAL_ID = '244637579'
const HUBSPOT_CONTACT_REGION = 'na2'
const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed'
const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js`
const hubspotContactFormIds: Record<Locale, string> = {
en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262',
'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93'
}
const hasEmbedLoadError = ref(false)
const hubspotContactFormId = computed(() => hubspotContactFormIds[locale])
const hubspotFormStyles: Record<`--${string}`, string> = {
'--hsf-global__font-family': "'PP Formula', sans-serif",
'--hsf-global__color': '#c2bfb9',
'--hsf-background__background-color': '#211927',
'--hsf-background__border-width': '0',
'--hsf-background__padding': '0',
'--hsf-button__font-family': "'PP Formula', sans-serif",
'--hsf-button__font-size': '14px',
'--hsf-button__color': '#211927',
'--hsf-button__background-color': '#f2ff59',
'--hsf-button__border-radius': '16px',
'--hsf-button__padding': '10px 24px',
'--hsf-richtext__font-family': "'PP Formula', sans-serif",
'--hsf-richtext__color': '#c2bfb9',
'--hsf-heading__font-family': "'PP Formula', sans-serif",
'--hsf-heading__color': '#c2bfb9',
'--hsf-field-label__font-family': "'PP Formula', sans-serif",
'--hsf-field-label__font-size': '12px',
'--hsf-field-label__color': '#c2bfb9',
'--hsf-field-description__font-family': "'PP Formula', sans-serif",
'--hsf-field-description__color': '#c2bfb9',
'--hsf-field-footer__font-family': "'PP Formula', sans-serif",
'--hsf-field-footer__color': '#c2bfb9',
'--hsf-field-input__font-family': "'PP Formula', sans-serif",
'--hsf-field-input__color': '#c2bfb9',
'--hsf-field-input__background-color': '#2a2230',
'--hsf-field-input__placeholder-color': '#585159',
'--hsf-field-input__border-color': '#3b3539',
'--hsf-field-input__border-width': '1px',
'--hsf-field-input__border-style': 'solid',
'--hsf-field-input__border-radius': '16px',
'--hsf-field-input__padding': '16px',
'--hsf-field-textarea__font-family': "'PP Formula', sans-serif",
'--hsf-field-textarea__color': '#c2bfb9',
'--hsf-field-textarea__background-color': '#2a2230',
'--hsf-field-textarea__placeholder-color': '#585159',
'--hsf-field-textarea__border-color': '#3b3539',
'--hsf-field-textarea__border-width': '1px',
'--hsf-field-textarea__border-style': 'solid',
'--hsf-field-textarea__border-radius': '16px',
'--hsf-field-textarea__padding': '16px',
'--hsf-field-checkbox__color': '#c2bfb9',
'--hsf-field-checkbox__background-color': '#2a2230',
'--hsf-field-checkbox__border-color': '#464147',
'--hsf-field-checkbox__border-width': '1px',
'--hsf-field-checkbox__border-style': 'solid',
'--hsf-field-radio__color': '#c2bfb9',
'--hsf-field-radio__background-color': '#2a2230',
'--hsf-field-radio__border-color': '#464147',
'--hsf-field-radio__border-width': '1px',
'--hsf-field-radio__border-style': 'solid',
'--hsf-erroralert__font-family': "'PP Formula', sans-serif",
'--hsf-infoalert__font-family': "'PP Formula', sans-serif"
}
onMounted(() => {
if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return
const script = document.createElement('script')
script.id = HUBSPOT_CONTACT_SCRIPT_ID
script.src = HUBSPOT_CONTACT_SCRIPT_SRC
script.defer = true
script.addEventListener(
'error',
() => {
hasEmbedLoadError.value = true
script.remove()
},
{ once: true }
)
document.head.append(script)
})
</script>
<template>
<div class="min-h-[640px] w-full">
<p
v-if="hasEmbedLoadError"
class="text-primary-comfy-canvas text-sm/6"
role="status"
>
{{ t('contact.form.embedLoadErrorPrefix', locale) }}
<a
class="text-primary-comfy-yellow underline"
href="mailto:hello@comfy.org"
>
hello@comfy.org
</a>
{{ t('contact.form.embedLoadErrorSuffix', locale) }}
</p>
<div
v-else
:key="hubspotContactFormId"
class="hs-form-html"
:style="hubspotFormStyles"
:data-region="HUBSPOT_CONTACT_REGION"
:data-form-id="hubspotContactFormId"
:data-portal-id="HUBSPOT_CONTACT_PORTAL_ID"
/>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<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="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-10 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t('customers.contact.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-4 max-w-2xl text-3xl font-light whitespace-pre-line"
v-html="t('customers.contact.heading', locale)"
/>
</section>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
@@ -16,12 +15,8 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('gallery.contact.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line"
>
{{ t('gallery.contact.heading', locale) }}
</h2>
<BrandButton href="/contact" variant="outline-dark" size="sm" class="mt-8">
{{ t('gallery.contact.cta', locale) }}
</BrandButton>
class="text-primary-comfy-canvas mt-4 max-w-2xl text-2xl font-light whitespace-pre-line"
v-html="t('gallery.contact.heading', locale)"
/>
</section>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { src, clipId, objectPosition } = defineProps<{
src: string
clipId: string
objectPosition?: 'top' | 'bottom' | 'center'
}>()
const positionClass = {
top: 'object-top',
bottom: 'object-bottom',
center: 'object-center'
} as const
function isVideo(url: string): boolean {
return url.endsWith('.webm')
}
</script>
<template>
<div
class="relative size-full overflow-hidden"
:style="`clip-path: url(#${clipId})`"
>
<Transition name="crossfade">
<video
v-if="isVideo(src)"
:key="`video-${src}`"
:src
:poster="src.replace('.webm', '.webp')"
autoplay
muted
loop
playsinline
disablepictureinpicture
aria-hidden="true"
:class="
cn(
'absolute inset-0 size-full object-cover',
objectPosition && positionClass[objectPosition]
)
"
/>
<img
v-else
:key="`img-${src}`"
:src
alt=""
aria-hidden="true"
:class="
cn(
'absolute inset-0 size-full object-cover',
objectPosition && positionClass[objectPosition]
)
"
/>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { side } = defineProps<{
side: 'left' | 'right'
}>()
</script>
<template>
<div
class="pointer-events-none flex min-h-0 items-center overflow-clip lg:overflow-visible"
>
<div
:class="
cn(
'aspect-2/3 w-40 lg:aspect-auto lg:h-[72vh] lg:max-h-240 lg:w-[115%]',
side === 'left' ? 'ml-auto lg:-ml-[12%]' : 'mr-auto lg:-mr-[12%]'
)
"
>
<slot />
</div>
</div>
</template>

View File

@@ -35,7 +35,10 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -52,12 +55,8 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -25,8 +25,7 @@ const steps = [
},
{
number: '2',
title: t('getStarted.step2.title', locale),
description: t('getStarted.step2.description', locale)
title: t('getStarted.step2.title', locale)
},
{
number: '3',
@@ -74,6 +73,17 @@ const steps = [
>
{{ step.description }}
</p>
<p
v-else-if="step.number === '2'"
class="text-primary-comfy-canvas flex-1 text-sm"
>
{{ t('getStarted.step2.descriptionPrefix', locale)
}}<a
:href="externalLinks.workflows"
class="text-primary-comfy-yellow hover:underline"
>{{ t('getStarted.step2.descriptionLink', locale) }}</a
>{{ t('getStarted.step2.descriptionSuffix', locale) }}
</p>
<p v-if="step.links" class="flex-1 text-sm">
<template v-for="(link, i) in step.links" :key="link.href">
<a

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
@@ -32,6 +34,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
>
{{ t('hero.runFirstWorkflow', locale) }}
</BrandButton>
</div>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useTemplateRefsList } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { ref, useTemplateRef, watch } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -37,6 +37,12 @@ const badgeSegments = [
const activeIndex = ref(0)
const videoRefs = useTemplateRefsList<HTMLVideoElement>()
const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
const isVisible = ref(false)
useIntersectionObserver(sectionRef, ([entry]) => {
isVisible.value = entry?.isIntersecting ?? false
})
watch(activeIndex, (current, previous) => {
videoRefs.value[previous]?.pause()
@@ -49,7 +55,7 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section class="px-4 py-20 lg:px-20 lg:py-24">
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />
@@ -66,23 +72,13 @@ watch(activeIndex, (current, previous) => {
<!-- Video area (desktop only) -->
<div class="hidden flex-1 lg:flex">
<div
class="rounded-5xl relative flex w-full items-center justify-center overflow-hidden p-0.5 [clip-path:inset(0_round_var(--radius-5xl))]"
:class="
cn(
'rounded-5xl relative flex w-full items-center justify-center overflow-hidden p-0.5',
isVisible && 'animate-border-spin'
)
"
>
<div
class="animate-border-spin absolute top-1/2 left-1/2 aspect-square min-h-full min-w-full -translate-1/2 scale-150"
style="
background: conic-gradient(
from 0deg,
color-mix(
in srgb,
var(--color-primary-comfy-yellow) 4%,
transparent
)
0%,
var(--color-primary-comfy-yellow) 100%
);
"
/>
<div
class="bg-primary-comfy-ink relative size-full overflow-hidden rounded-[calc(2.5rem-2px)]"
>
@@ -92,12 +88,13 @@ watch(activeIndex, (current, previous) => {
:key="feature.title"
:src="feature.video"
:autoplay="i === 0"
:preload="i === 0 ? 'metadata' : 'none'"
loop
muted
playsinline
:class="
cn(
'absolute inset-0 size-full object-cover transition-opacity duration-300',
'absolute inset-0 size-full object-cover transition-opacity duration-300 will-change-[opacity]',
activeIndex === i ? 'opacity-100' : 'opacity-0'
)
"
@@ -112,21 +109,24 @@ watch(activeIndex, (current, previous) => {
<!-- Video area (mobile, rendered before active item) -->
<div
v-if="activeIndex === i"
:class="
cn(
'aspect-video overflow-hidden rounded-4xl lg:hidden',
i !== 0 && 'mt-4'
)
"
:class="cn('aspect-video lg:hidden', i !== 0 && 'mt-4')"
>
<video
:src="feature.video"
autoplay
loop
muted
playsinline
class="size-full object-cover"
/>
<div
class="animate-border-spin size-full overflow-hidden rounded-4xl p-0.5"
>
<div
class="bg-primary-comfy-ink size-full overflow-hidden rounded-[calc(2rem-2px)]"
>
<video
:src="feature.video"
autoplay
loop
muted
playsinline
class="size-full object-cover"
/>
</div>
</div>
</div>
<!-- Connector (mobile) -->

View File

@@ -1,17 +1,27 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref, useId } from 'vue'
import { whenever } from '@vueuse/core'
import { computed, ref, useId, watch } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useParallax } from '../../composables/useParallax'
import { usePinScrub } from '../../composables/usePinScrub'
import { usePinScrub, VH_PER_ITEM } from '../../composables/usePinScrub'
import BrandButton from '../common/BrandButton.vue'
import BlobMedia from './BlobMedia.vue'
import BlobRail from './BlobRail.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const categories = [
interface Category {
label: string
leftSrc: string
rightSrc: string
rightObjectPosition?: 'top' | 'bottom' | 'center'
}
const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
@@ -40,10 +50,6 @@ const categories = [
}
]
function isVideo(src: string): boolean {
return src.endsWith('.webm')
}
const sectionRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const navRef = ref<HTMLElement>()
@@ -52,7 +58,8 @@ const rightImgRef = ref<HTMLElement>()
const {
activeIndex: activeCategory,
isActive: isPinned,
isEnabled,
isPinned,
scrollToIndex
} = usePinScrub(
{ section: sectionRef, content: contentRef, nav: navRef },
@@ -69,15 +76,57 @@ const uid = useId()
const leftBlobId = `left-blob-${uid}`
const rightBlobId = `right-blob-${uid}`
const pinScrubEnd = `+=${categories.length * 100}%`
function navButtons() {
return navRef.value?.querySelectorAll<HTMLButtonElement>(':scope > button')
}
whenever(isPinned, () => {
navButtons()?.[activeCategory.value]?.focus({ preventScroll: true })
})
watch(activeCategory, (index) => {
if (!isPinned.value) return
navButtons()?.[index]?.focus({ preventScroll: true })
})
function onNavKeydown(event: KeyboardEvent) {
const delta = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0
if (!delta) return
event.preventDefault()
const current = activeCategory.value
const next = current + delta
if (next < 0 || next >= categories.length) {
navButtons()?.[current]?.blur()
return
}
scrollToIndex(next)
navButtons()?.[next]?.focus({ preventScroll: true })
}
function travelRange(el: HTMLElement) {
if (window.matchMedia('(min-width: 1024px)').matches) return 150
const rail = el.parentElement?.parentElement
if (!rail) return 0
const pb = parseFloat(getComputedStyle(rail).paddingBottom)
return Math.max(0, (rail.clientHeight - pb - el.offsetHeight) / 2)
}
const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%`
useParallax([rightImgRef], {
trigger: sectionRef,
fromY: (el) => -travelRange(el),
y: (el) => travelRange(el),
start: 'top top',
end: pinScrubEnd
})
useParallax([leftImgRef], {
trigger: sectionRef,
y: -60,
fromY: (el) => travelRange(el),
y: (el) => -travelRange(el),
start: 'top top',
end: pinScrubEnd
})
@@ -88,12 +137,11 @@ useParallax([leftImgRef], {
ref="sectionRef"
:class="
cn(
'bg-primary-comfy-ink relative isolate px-8 py-20 lg:px-0 lg:py-24',
isPinned && 'overflow-x-clip lg:h-[calc(100vh+60px)]'
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
isEnabled && 'lg:h-[calc(100vh+60px)]'
)
"
>
<!-- Clip-path definitions for shaped images -->
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
<defs>
<clipPath :id="leftBlobId" clipPathUnits="objectBoundingBox">
@@ -110,64 +158,35 @@ useParallax([leftImgRef], {
</svg>
<div
class="relative mx-auto grid w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)] lg:h-full lg:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)_minmax(0,1fr)]"
class="relative mx-auto grid w-full grid-cols-[minmax(0,5rem)_minmax(18ch,1fr)_minmax(0,5rem)] grid-rows-[auto_minmax(0,1fr)_auto] lg:h-full lg:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)_minmax(0,1fr)] lg:gap-x-0"
>
<!-- Label row spanning all columns -->
<div
class="from-primary-comfy-ink to-primary-comfy-ink/10 relative z-20 col-span-full bg-linear-to-b py-4"
>
<!-- Label row -->
<div class="relative z-20 col-span-full flex justify-center py-4">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase lg:text-base"
class="text-primary-comfy-yellow from-primary-comfy-ink to-primary-comfy-ink/10 shrink grow-0 bg-linear-to-b text-center text-sm font-bold tracking-widest uppercase lg:text-base"
>
{{ t('useCase.label', locale) }}
</p>
</div>
<!-- Left image -->
<div
class="pointer-events-none relative hidden min-h-0 lg:flex lg:items-center lg:justify-start"
>
<div class="w-[115%] -translate-x-[12%]">
<div
ref="leftImgRef"
class="relative h-[72vh] max-h-240 w-full overflow-hidden will-change-transform"
:style="`clip-path: url(#${leftBlobId})`"
>
<Transition name="crossfade">
<video
v-if="isVideo(activeLeft)"
:key="`video-${activeLeft}`"
:src="activeLeft"
autoplay
muted
loop
playsinline
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
/>
<img
v-else
:key="`img-${activeLeft}`"
:src="activeLeft"
alt=""
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
</div>
<!-- Left blob rail -->
<BlobRail side="left">
<div ref="leftImgRef" class="size-full will-change-transform">
<BlobMedia :src="activeLeft" :clip-id="leftBlobId" />
</div>
</div>
</BlobRail>
<!-- Center content -->
<div class="relative z-10 min-h-0 overflow-hidden">
<div
ref="contentRef"
class="flex flex-col items-center will-change-transform"
class="flex h-full flex-col items-center will-change-transform"
>
<nav
ref="navRef"
class="mt-16 flex w-full max-w-5/6 flex-col items-center justify-center gap-12 lg:mt-20 lg:max-w-none lg:gap-8"
aria-label="Industry categories"
class="mt-16 flex w-full max-w-5/6 flex-1 flex-col items-center justify-evenly gap-12 lg:mt-[clamp(0.5rem,3vh,5rem)] lg:max-w-none lg:gap-[clamp(0.25rem,1vh,2rem)]"
:aria-label="t('useCase.navLabel', locale)"
@keydown="onNavKeydown"
>
<button
v-for="(category, index) in categories"
@@ -175,7 +194,7 @@ useParallax([leftImgRef], {
type="button"
:class="
cn(
'lg:text-4.5xl cursor-pointer text-center text-4xl font-light whitespace-pre-line transition-colors',
'cursor-pointer text-center text-2xl font-light whitespace-pre-line transition-colors outline-none lg:text-[clamp(1rem,5vh,3rem)]',
index === activeCategory
? 'text-primary-comfy-canvas'
: 'text-primary-comfy-canvas/30 hover:text-primary-comfy-canvas/50'
@@ -187,66 +206,29 @@ useParallax([leftImgRef], {
{{ category.label }}
</button>
</nav>
<p
class="text-primary-warm-gray mt-20 max-w-md text-center text-base"
>
{{ t('useCase.body', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
class="mt-8"
>
{{ t('useCase.cta', locale) }}
</BrandButton>
</div>
</div>
<!-- Right image -->
<div
class="pointer-events-none relative hidden min-h-0 lg:flex lg:items-center lg:justify-end"
>
<div class="w-[115%] translate-x-[12%]">
<div
ref="rightImgRef"
class="relative h-[72vh] max-h-240 w-full overflow-hidden will-change-transform"
:style="`clip-path: url(#${rightBlobId})`"
>
<Transition name="crossfade">
<video
v-if="isVideo(activeRight)"
:key="`video-${activeRight}`"
:src="activeRight"
autoplay
muted
loop
playsinline
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
:style="
activeRightObjectPosition
? `object-position: ${activeRightObjectPosition}`
: undefined
"
/>
<img
v-else
:key="`img-${activeRight}`"
:src="activeRight"
alt=""
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
:style="
activeRightObjectPosition
? `object-position: ${activeRightObjectPosition}`
: undefined
"
/>
</Transition>
</div>
<!-- Right blob rail -->
<BlobRail side="right">
<div ref="rightImgRef" class="size-full will-change-transform">
<BlobMedia
:src="activeRight"
:clip-id="rightBlobId"
:object-position="activeRightObjectPosition"
/>
</div>
</BlobRail>
<div
class="col-span-full mt-8 flex flex-col items-center gap-8 px-4 lg:mt-[clamp(0.25rem,1vh,2rem)] lg:gap-[clamp(0.25rem,1vh,2rem)]"
>
<p class="text-primary-warm-gray max-w-md text-center text-base">
{{ t('useCase.body', locale) }}
</p>
<BrandButton :href="externalLinks.workflows" variant="outline">
{{ t('useCase.cta', locale) }}
</BrandButton>
</div>
</div>
</section>

View File

@@ -77,10 +77,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
features: [{ text: 'pricing.plan.creator.feature1' }],
isPopular: true
},
{
@@ -93,10 +90,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
features: [{ text: 'pricing.plan.pro.feature1' }]
},
{
id: 'enterprise',
@@ -104,14 +98,7 @@ const plans: PricingPlan[] = [
summaryKey: 'pricing.enterprise.description',
ctaKey: 'pricing.enterprise.cta',
ctaHref: getRoutes(locale).cloudEnterprise,
featureIntroKey: 'pricing.enterprise.featureIntro',
features: [
{ text: 'pricing.enterprise.feature1' },
{ text: 'pricing.enterprise.feature2' },
{ text: 'pricing.enterprise.feature3' },
{ text: 'pricing.enterprise.feature4' }
],
andMoreKey: 'pricing.enterprise.andMore',
features: [],
isEnterprise: true
}
]
@@ -229,7 +216,7 @@ const activePlanIndex = ref(0)
<div v-else class="px-6" />
<!-- Features -->
<div class="px-6 py-3">
<div v-if="plan.features.length" class="px-6 py-3">
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
@@ -356,7 +343,10 @@ const activePlanIndex = ref(0)
</div>
<!-- Features card -->
<div class="bg-transparency-white-t4 mt-2 rounded-3xl p-6">
<div
v-if="plan.features.length"
class="bg-transparency-white-t4 mt-2 rounded-3xl p-6"
>
<PricingPlanFeatureList
:features="plan.features"
:feature-intro-key="plan.featureIntroKey"

View File

@@ -1,67 +1,59 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
interface IncludedFeature {
title: string
description: string
titleKey: TranslationKey
descriptionKey: TranslationKey
isComingSoon?: boolean
}
const features: IncludedFeature[] = [
{
title: 'Machine Setup',
description: 'Comfy Cloud runs on Blackwell RTX 6000 Pro 96GB VRAM'
titleKey: 'pricing.included.feature1.title',
descriptionKey: 'pricing.included.feature1.description'
},
{
title: 'Time limit per job',
description:
'Each workflow run has a maximum duration of 30 minutes. On the Pro plan, the time limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.'
titleKey: 'pricing.included.feature2.title',
descriptionKey: 'pricing.included.feature2.description'
},
{
title: 'Usage',
description:
"You're only charged for <strong>active GPU</strong> time while a workflow is running. Idle time (e.g. time spent building workflows) does not consume GPU hours."
titleKey: 'pricing.included.feature3.title',
descriptionKey: 'pricing.included.feature3.description'
},
{
title: 'Credit balance',
description:
'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.'
titleKey: 'pricing.included.feature4.title',
descriptionKey: 'pricing.included.feature4.description'
},
{
title: 'Add more credits anytime',
description:
'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.'
titleKey: 'pricing.included.feature5.title',
descriptionKey: 'pricing.included.feature5.description'
},
{
title: 'Pre-installed models',
description: 'Access a library of 900+ pre-installed models.'
titleKey: 'pricing.included.feature6.title',
descriptionKey: 'pricing.included.feature6.description'
},
{
title: 'Custom nodes support',
description:
'Comfy Cloud currently supports a variety of the most-used custom nodes from the ComfyUI community. <a href="https://docs.comfy.org" class="text-primary-comfy-yellow underline">Check out which nodes we support currently.</a> We\'re expanding support regularly based on demand and compatibility. <a href="https://docs.comfy.org" class="text-primary-comfy-yellow underline">See which nodes we\'re working on adding.</a>'
titleKey: 'pricing.included.feature7.title',
descriptionKey: 'pricing.included.feature7.description'
},
{
title: 'Partner Nodes',
description:
'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.'
titleKey: 'pricing.included.feature8.title',
descriptionKey: 'pricing.included.feature8.description'
},
{
title: 'Job queue',
description: 'Queue up to 100 workflows at once.'
titleKey: 'pricing.included.feature9.title',
descriptionKey: 'pricing.included.feature9.description'
},
{
title: 'Custom LoRA importing',
description:
'For those on the Creator or Pro plans, you can bring in your own models & LoRAs from CivitAI or Huggingface to perfect your own style.'
titleKey: 'pricing.included.feature10.title',
descriptionKey: 'pricing.included.feature10.description'
},
{
title: 'Parallel job execution',
description:
'Run multiple workflows in parallel to speed up your pipeline.',
titleKey: 'pricing.included.feature11.title',
descriptionKey: 'pricing.included.feature11.description',
isComingSoon: true
}
]
@@ -85,7 +77,7 @@ const features: IncludedFeature[] = [
<div>
<div
v-for="(feature, index) in features"
:key="feature.title"
:key="feature.titleKey"
:class="
index < features.length - 1
? 'border-primary-comfy-canvas/15 border-b border-solid'
@@ -109,23 +101,15 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ feature.title }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
coming soon
</span>
</div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->
<p
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
v-html="feature.description"
v-html="t(feature.descriptionKey, locale)"
/>
</div>
</div>

View File

@@ -10,7 +10,7 @@ const features = [
{
title: t('api.automation.feature1.title', locale),
description: t('api.automation.feature1.description', locale),
image: 'https://media.comfy.org/website/gallery/desert.webp',
image: 'https://media.comfy.org/website/api/infrastructure-nodes.webp',
description2: t('api.automation.feature1.description2', locale)
},
{
@@ -22,7 +22,7 @@ const features = [
{
title: t('api.automation.feature3.title', locale),
description: t('api.automation.feature3.description', locale),
image: 'https://media.comfy.org/website/pricing/free.webp'
image: 'https://media.comfy.org/website/api/precision-tools.webp'
}
]
</script>

View File

@@ -223,14 +223,14 @@ onUnmounted(() => {
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
<BrandButton
:href="externalLinks.cloud"
:href="externalLinks.apiKeys"
size="lg"
class="text-center lg:min-w-60 lg:p-4"
>
{{ t('api.hero.getApiKeys', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.docs"
:href="externalLinks.docsApi"
variant="outline"
size="lg"
class="text-center lg:min-w-60 lg:p-4"

View File

@@ -13,13 +13,13 @@ const steps = [
number: '01',
titleKey: 'api.steps.step1.title' as const,
descriptionKey: 'api.steps.step1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
number: '02',
titleKey: 'api.steps.step2.title' as const,
descriptionKey: 'api.steps.step2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
},
{
number: '03',
@@ -61,7 +61,7 @@ const steps = [
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<BrandButton
:href="externalLinks.cloud"
:href="externalLinks.apiKeys"
variant="solid"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"
@@ -69,7 +69,7 @@ const steps = [
{{ t('api.hero.getApiKeys', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.docs"
:href="externalLinks.docsApi"
variant="outline"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"

View File

@@ -10,12 +10,12 @@ const cards = [
{
titleKey: 'enterprise.byoKey.card1.title' as const,
descriptionKey: 'enterprise.byoKey.card1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
titleKey: 'enterprise.byoKey.card2.title' as const,
descriptionKey: 'enterprise.byoKey.card2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
}
]
</script>

View File

@@ -16,9 +16,11 @@ const midRightRef = ref<HTMLElement>()
const bottomLeftRef = ref<HTMLElement>()
const bottomRightRef = ref<HTMLElement>()
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
</script>
<template>

View File

@@ -44,162 +44,303 @@ onMounted(() => {
<svg
ref="svgRef"
class="block size-full"
viewBox="600 -50 1000 1100"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<!-- Ripple rings -->
<path
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
height="800"
transform="translate(712 112)"
fill="#211927"
/>
<!-- Exploding block cluster -->
<g stroke="#4D3762" stroke-width="2">
<!-- Ripple rings -->
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
class="ripple-path delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
class="ripple-path delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
class="ripple-path delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
<!-- Exploding block cluster -->
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
style="pointer-events: none"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
/>
<defs>
<linearGradient
id="enterpriseHeroFade"
@@ -212,6 +353,9 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>
</svg>
</div>
@@ -255,13 +399,13 @@ onMounted(() => {
animation: ripple-effect 4s linear infinite;
}
.ripple-delay-1 {
.delay-1 {
animation-delay: -1s;
}
.ripple-delay-2 {
.delay-2 {
animation-delay: -2s;
}
.ripple-delay-3 {
.delay-3 {
animation-delay: -3s;
}
@@ -281,6 +425,11 @@ onMounted(() => {
}
}
.block-cluster {
transform-origin: center;
transform-box: fill-box;
}
.block-piece {
transform-origin: center;
transform-box: fill-box;

View File

@@ -21,17 +21,12 @@ const features = computed(() => [
{
title: t('enterprise.team.feature2.title', locale),
description: t('enterprise.team.feature2.description', locale),
image: 'https://media.comfy.org/website/enterprise/dark-fluid-texture.webp',
ctaText: t('enterprise.team.feature2.cta', locale),
ctaHref: routes.value.cloud
image: 'https://media.comfy.org/website/enterprise/dark-fluid-texture.webp'
},
{
title: t('enterprise.team.feature3.title', locale),
description: t('enterprise.team.feature3.description', locale),
image:
'https://media.comfy.org/website/enterprise/isometric-interface.webp',
ctaText: t('enterprise.hero.contactSales', locale),
ctaHref: routes.value.contact
image: 'https://media.comfy.org/website/enterprise/isometric-interface.webp'
}
])
</script>

View File

@@ -40,17 +40,16 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</div>
</div>
<!-- TODO: Replace with final ecosystem illustration -->
<div
class="rounded-4.5xl aspect-4/3 w-full flex-1 overflow-hidden bg-linear-to-b from-emerald-300 to-amber-200"
>
<div class="flex h-full items-center justify-center">
<span
class="bg-primary-comfy-ink text-primary-comfy-yellow rounded-full px-4 py-2 text-sm font-bold"
>
4x
</span>
</div>
<div class="rounded-4.5xl w-full flex-1 overflow-hidden">
<video
src="https://media.comfy.org/website/local/racer.webm"
autoplay
loop
muted
playsinline
aria-hidden="true"
class="size-full object-cover"
/>
</div>
</div>
</section>

View File

@@ -164,11 +164,11 @@ onUnmounted(() => {
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-visible lg:flex-row lg:items-center lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-[80px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
@@ -187,7 +187,7 @@ onUnmounted(() => {
rx="65.5036"
transform="matrix(-0.866025 -0.5 0 -1 620.969 1058.01)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
visibility="hidden"
/>
@@ -200,7 +200,7 @@ onUnmounted(() => {
rx="59.4123"
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
/>
<rect
@@ -212,7 +212,7 @@ onUnmounted(() => {
rx="59.4123"
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
/>
<rect
@@ -224,12 +224,12 @@ onUnmounted(() => {
rx="59.4123"
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
/>
<!-- Hex nodes -->
<g stroke="#7E7C78" stroke-width="6">
<g stroke="#4D3762" stroke-width="3">
<path
data-hex="5"
d="M722.595 427.826L722.579 491.728C722.576 500.223 728.536 510.551 735.889 514.796L791.205 546.733C795.862 549.422 804.238 549.298 808.894 546.607L864.227 514.642C871.583 510.392 877.548 500.061 877.55 491.566L877.567 427.664C877.568 422.286 873.487 414.972 868.829 412.283L813.514 380.347C806.16 376.101 794.236 376.104 786.88 380.354L731.548 412.319C726.882 415.015 722.597 422.437 722.595 427.826Z"
@@ -283,11 +283,12 @@ onUnmounted(() => {
y="150"
width="250"
height="900"
fill="url(#localHeroFade)"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFade"
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"

View File

@@ -12,10 +12,12 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>
{{ t('download.cloud.prefix', locale) }}
<span class="whitespace-nowrap">
{{ t('download.cloud.prefix', locale) }}
</span>
<a
:href="externalLinks.cloud"
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest uppercase hover:underline"
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest whitespace-nowrap uppercase hover:underline"
>
{{ t('download.cloud.cta', locale) }}
</a>

View File

@@ -29,14 +29,14 @@ const {
<template>
<section
class="flex flex-col gap-4 px-4 py-24 md:flex-row md:gap-16 md:px-20 md:py-40"
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
>
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-115 md:py-0"
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-primary-comfy-canvas text-4xl/16 font-light whitespace-pre-line md:text-5xl/16"
class="text-primary-comfy-canvas text-4xl/16 font-light whitespace-pre-line lg:text-5xl/16"
>
{{ t(headingKey, locale)
}}<span v-if="headingHighlightKey" :class="highlightClass">{{
@@ -56,14 +56,13 @@ const {
<div
v-for="reason in reasons"
:key="reason.titleKey"
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 md:flex-row md:gap-12"
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 lg:flex-row lg:gap-12"
>
<div class="shrink-0 md:w-52">
<div class="shrink-0 lg:w-84">
<h3
class="text-primary-comfy-canvas text-2xl font-light whitespace-pre-line"
>
{{ t(reason.titleKey, locale) }}
</h3>
v-html="t(reason.titleKey, locale)"
/>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="text-primary-comfy-canvas/70 flex-1 text-sm">

View File

@@ -3,45 +3,68 @@ import { onMounted, onUnmounted } from 'vue'
import { gsap } from '../scripts/gsapSetup'
import { prefersReducedMotion } from './useReducedMotion'
type ValueOrFn = number | ((el: HTMLElement, trigger: HTMLElement) => number)
function resolve(v: ValueOrFn, el: HTMLElement, trigger: HTMLElement): number {
return typeof v === 'function' ? v(el, trigger) : v
}
interface ParallaxOptions {
/** Vertical offset in pixels (default: 200) */
y?: number
/** Starting vertical offset in pixels, or a function resolved at mount */
fromY?: ValueOrFn
/** Ending vertical offset in pixels, or a function resolved at mount */
y?: ValueOrFn
trigger?: Ref<HTMLElement | undefined>
/** ScrollTrigger start value (default: 'top bottom') */
start?: string
/** ScrollTrigger end value (default: 'bottom top') */
end?: string
/** Media query string — animation only runs when matched (responsive) */
mediaQuery?: string
}
export function useParallax(
elements: Ref<HTMLElement | undefined>[],
options: ParallaxOptions = {}
) {
const { y = 200 } = options
let ctx: gsap.Context | undefined
const { fromY = 0, y = 200 } = options
let ctx: gsap.Context | gsap.MatchMedia | undefined
onMounted(() => {
const trigger = options.trigger?.value
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length || prefersReducedMotion()) return
if (prefersReducedMotion()) return
const triggerEl = options.trigger?.value
const createAnimations = () => {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
ctx = gsap.context(() => {
els.forEach((el) => {
gsap.to(el, {
y,
ease: 'none',
scrollTrigger: {
trigger: trigger ?? el,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1,
invalidateOnRefresh: true
}
})
gsap.fromTo(
el,
{ y: resolve(fromY, el, trigger) },
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
)
})
})
}
if (options.mediaQuery) {
const mm = gsap.matchMedia()
mm.add(options.mediaQuery, createAnimations)
ctx = mm
} else {
ctx = gsap.context(createAnimations)
}
})
onUnmounted(() => {

View File

@@ -1,5 +1,6 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { ScrollTrigger } from '../scripts/gsapSetup'
import { gsap } from '../scripts/gsapSetup'
import { scrollTo } from '../scripts/smoothScroll'
import { prefersReducedMotion } from './useReducedMotion'
@@ -12,10 +13,13 @@ interface PinScrubRefs {
interface PinScrubOptions {
itemCount: number
/** Viewport-height percentage per item (default: 100) */
/** Viewport-height percentage per item (default: 20) */
vhPerItem?: number
}
/** Viewport-height percentage each category occupies in the scroll distance. */
export const VH_PER_ITEM = 20
function interpolateY(
index: number,
buttonCenters: number[],
@@ -38,11 +42,12 @@ function interpolateY(
export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
const activeIndex = ref(0)
const isActive = ref(false)
const isEnabled = ref(false)
const isPinned = ref(false)
let ctx: gsap.Context | undefined
let scrollTriggerInstance: ScrollTrigger | undefined
const vhPerItem = options.vhPerItem ?? 100
const vhPerItem = options.vhPerItem ?? VH_PER_ITEM
function scrollToIndex(index: number) {
if (!scrollTriggerInstance) {
@@ -77,6 +82,14 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
const sectionStyle = getComputedStyle(section)
contentH = content.scrollHeight
vpH = window.innerHeight - parseFloat(sectionStyle.paddingTop)
// Ensure the section is tall enough for its content so nothing clips
const naturalH = section.scrollHeight
const viewportH = window.innerHeight + 60
if (naturalH > viewportH) {
section.style.height = `${naturalH}px`
}
buttonCenters = Array.from(nav.querySelectorAll(':scope > *')).map(
(btn) => {
const btnRect = btn.getBoundingClientRect()
@@ -86,45 +99,50 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
}
cacheLayout()
isActive.value = true
isEnabled.value = true
const setContentY = gsap.quickSetter(content, 'y', 'px')
const proxy = { index: 0 }
ctx = gsap.context(() => {
gsap.to(proxy, {
index: options.itemCount - 1,
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: `+=${options.itemCount * vhPerItem}%`,
pin: true,
scrub: true,
refreshPriority: 1,
onRefresh: cacheLayout,
onUpdate(self) {
scrollTriggerInstance = self
}
},
onUpdate() {
activeIndex.value = Math.round(proxy.index)
const tween = gsap.to(
{},
{
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: `+=${options.itemCount * vhPerItem}%`,
pin: true,
scrub: true,
onToggle(self) {
isPinned.value = self.isActive
},
onRefresh: cacheLayout,
onUpdate(self) {
const index = self.progress * (options.itemCount - 1)
const nextActive = Math.round(index)
if (contentH <= vpH) {
gsap.set(content, { y: 0 })
return
}
if (nextActive !== activeIndex.value) {
activeIndex.value = nextActive
}
gsap.set(content, {
y: interpolateY(proxy.index, buttonCenters, contentH, vpH)
})
if (contentH <= vpH) {
setContentY(0)
return
}
setContentY(interpolateY(index, buttonCenters, contentH, vpH))
}
}
}
})
)
scrollTriggerInstance = tween.scrollTrigger as ScrollTrigger
})
})
onUnmounted(() => {
isActive.value = false
isEnabled.value = false
ctx?.revert()
})
return { activeIndex, isActive, scrollToIndex }
return { activeIndex, isEnabled, isPinned, scrollToIndex }
}

View File

@@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [
detailPrefix: 'customers.detail.ubisoft-chord',
readMoreHref:
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
},
{
slug: 'groove-jones',
image:
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
category: 'customers.story.groove-jones.category',
title: 'customers.story.groove-jones.title',
body: 'customers.story.groove-jones.body',
detailPrefix: 'customers.detail.groove-jones'
}
]

View File

@@ -27,11 +27,14 @@ export function getRoutes(locale: Locale = 'en'): Routes {
}
export const externalLinks = {
cloud: 'https://cloud.comfy.org',
workflows: 'https://comfy.org/workflows',
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
github: 'https://github.com/Comfy-Org/ComfyUI',
cloud: 'https://cloud.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
github: 'https://github.com/Comfy-Org/ComfyUI',
platform: 'https://platform.comfy.org',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -0,0 +1,176 @@
{
"fetchedAt": "2026-05-02T20:15:18.321Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
},
{
"id": "7bb02634a24763bc",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
}
]
},
{
"name": "ENGINEERING",
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
},
{
"id": "d01d69fba7743905",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
},
{
"id": "f36f60cfd5bb5910",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
},
{
"id": "9d8ec4c65e20b19e",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
},
{
"id": "be94b193d1f4d482",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
},
{
"id": "ab48f5db6bd1783c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
},
{
"id": "c5dff4ee628bdcd1",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
},
{
"id": "4302a7aaa87e16e3",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
},
{
"id": "2eb53e8943cc9396",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
}
]
},
{
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
},
{
"id": "130d7218d7895bdb",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
{
"name": "OPERATIONS",
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Talent Lead",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
export interface Role {
id: string
title: string
department: string
location: string
applyUrl: string
}
export interface Department {
name: string
key: string
roles: Role[]
}
export interface RolesSnapshot {
fetchedAt: string
departments: Department[]
}

View File

@@ -4,12 +4,16 @@ const translations = {
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
'zh-CN': '视觉 AI 的\n专业控制'
'zh-CN': '视觉 AI 的\n最强可控性'
},
'hero.subtitle': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
},
'hero.runFirstWorkflow': {
en: 'Run your first workflow',
'zh-CN': '运行你的第一个工作流'
},
// ProductShowcaseSection
@@ -20,11 +24,11 @@ const translations = {
},
'showcase.subtitle2': {
en: 'Start from a community template or build from scratch.',
'zh-CN': '从社区模板开始,或从零构建。'
'zh-CN': '从工作流模板开始,或从零构建。'
},
'showcase.feature1.title': {
en: 'Full Control with Nodes',
'zh-CN': '节点式完全控制'
'zh-CN': '节点带来的可控性'
},
'showcase.feature1.description': {
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
@@ -49,14 +53,18 @@ const translations = {
'zh-CN':
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
},
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运' },
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
// UseCaseSection
'useCase.label': {
en: 'Industries that create with ComfyUI',
'zh-CN': '使用 ComfyUI 创作的行业'
},
'useCase.navLabel': {
en: 'Industry categories',
'zh-CN': '行业分类'
},
'useCase.vfx': {
en: 'VFX &\nAnimation',
'zh-CN': '视觉特效 &\n动画'
@@ -66,7 +74,7 @@ const translations = {
'zh-CN': '广告 &\n创意工作室'
},
'useCase.gaming': {
en: 'Video Games',
en: 'Gaming',
'zh-CN': '电子游戏'
},
'useCase.ecommerce': {
@@ -79,8 +87,7 @@ const translations = {
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
},
'useCase.cta': {
en: 'EXPLORE WORKFLOWS',
@@ -116,9 +123,17 @@ const translations = {
en: 'Load a workflow',
'zh-CN': '加载工作流'
},
'getStarted.step2.description': {
en: 'Start from a community template or build your own.',
'zh-CN': '从社区模板开始,或自行构建。'
'getStarted.step2.descriptionPrefix': {
en: 'Start from ',
'zh-CN': '从'
},
'getStarted.step2.descriptionLink': {
en: 'a community template',
'zh-CN': '社区模板'
},
'getStarted.step2.descriptionSuffix': {
en: ' or build your own.',
'zh-CN': '开始,或自行构建。'
},
'getStarted.step3.title': {
en: 'Generate & Iterate',
@@ -152,7 +167,7 @@ const translations = {
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版性'
'zh-CN': '查看本地版性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -164,7 +179,7 @@ const translations = {
},
'products.cloud.cta': {
en: 'SEE CLOUD FEATURES',
'zh-CN': '查看云端性'
'zh-CN': '查看云端性'
},
'products.api.title': {
en: 'Comfy\nAPI',
@@ -176,7 +191,7 @@ const translations = {
},
'products.api.cta': {
en: 'SEE API FEATURES',
'zh-CN': '查看 API 性'
'zh-CN': '查看 API 性'
},
'products.enterprise.title': {
en: 'Comfy\nEnterprise',
@@ -188,7 +203,7 @@ const translations = {
},
'products.enterprise.cta': {
en: 'SEE ENTERPRISE FEATURES',
'zh-CN': '查看企业版性'
'zh-CN': '查看企业版性'
},
// CaseStudySpotlightSection
@@ -197,12 +212,12 @@ const translations = {
'zh-CN': '客户故事'
},
'caseStudy.heading': {
en: 'See Comfy\nin the real world',
'zh-CN': '看看 Comfy\n在真实世界中的应用'
en: 'See ComfyUI\nin the real world',
'zh-CN': '看看 ComfyUI\n在真实世界中的应用'
},
'caseStudy.subheading': {
en: 'Videos & case studies from teams building with Comfy',
'zh-CN': '来自使用 Comfy 构建的团队的视频和案例研究'
en: 'Videos & case studies from teams building with ComfyUI',
'zh-CN': '来自使用 ComfyUI 构建的团队的视频和案例研究'
},
'caseStudy.seeAll': {
en: 'SEE ALL CASE STUDIES',
@@ -730,7 +745,7 @@ const translations = {
'zh-CN': 'Cloud'
},
'cloud.reason.1.title': {
en: 'Powerful GPUs with end-\nto-end security built-in',
en: 'Powerful GPUs with <span class="whitespace-nowrap">end-to-end</span> security <span class="whitespace-nowrap">built-in</span>',
'zh-CN': '强大 GPU\n端到端安全内置'
},
'cloud.reason.1.description': {
@@ -765,7 +780,7 @@ const translations = {
'每个节点都可见。每个设置都可调。ComfyUI 为您提供完整的推理管线。选择您的采样器、调度器、模型链。云端简化了设置并增强了底层硬件。'
},
'cloud.reason.4.title': {
en: 'Community workflows,\nunlimited customization\nthrough pre-installed\ncustom nodes',
en: 'Community workflows,\nunlimited customization\nthrough <span class="whitespace-nowrap">pre-installed</span>\ncustom nodes',
'zh-CN': '社区工作流,\n通过预安装自定义节点\n实现无限自定义'
},
'cloud.reason.4.description': {
@@ -899,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -951,9 +966,9 @@ const translations = {
'zh-CN': '我可以运行长时间或多个工作流吗?'
},
'cloud.faq.9.a': {
en: "Each workflow can run for up to 30 minutes, with one active job at a time. We're adding higher tiers and parallel runs soon for even more flexibility.",
en: "Each workflow can run for up to 60 minutes, with one active job at a time. We're adding higher tiers and parallel runs soon for even more flexibility.",
'zh-CN':
'每个工作流最长可运行 30 分钟,同时运行一个活跃任务。我们即将推出更高层级和并行运行,提供更大灵活性。'
'每个工作流最长可运行 60 分钟,同时运行一个活跃任务。我们即将推出更高层级和并行运行,提供更大灵活性。'
},
'cloud.faq.10.q': {
en: 'How is my user data stored and secured in Comfy Cloud?',
@@ -1081,7 +1096,7 @@ const translations = {
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
'pricing.plan.creator.summary': {
en: 'Small teams building fine-tuned, repeatable workflows',
'zh-CN': '面向专业人士与小团队构建可复用、精细调优的工作流'
'zh-CN': '小团队构建精细调优、可复用的工作流'
},
'pricing.plan.creator.price': { en: '$35', 'zh-CN': '$35' },
'pricing.plan.creator.credits': {
@@ -1104,10 +1119,6 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: 'Up to 5 seats per workspace (coming soon!)',
'zh-CN': '每个工作区最多 5 个席位(即将上线)'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1132,10 +1143,6 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: 'Up to 20 seats per workspace (coming soon!)',
'zh-CN': '每个工作区最多 20 个席位(即将上线)'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
@@ -1146,7 +1153,7 @@ const translations = {
en: 'For teams running Comfy in production, and at scale.',
'zh-CN': '面向在生产环境和规模化场景中运行 Comfy 的团队。'
},
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '联系我们' },
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
'pricing.enterprise.featureIntro': {
en: 'Everything in Pro, plus:',
'zh-CN': '包含专业版全部能力,另加:'
@@ -1178,6 +1185,101 @@ const translations = {
en: "What's included\nin the Comfy plan",
'zh-CN': 'Comfy 计划\n包含哪些内容'
},
'pricing.included.feature1.title': {
en: 'Machine Setup',
'zh-CN': '机器配置'
},
'pricing.included.feature1.description': {
en: 'Comfy Cloud runs on Blackwell RTX 6000 Pro 96GB VRAM',
'zh-CN': 'Comfy Cloud 运行在 Blackwell RTX 6000 Pro 上,配备 96GB 显存'
},
'pricing.included.feature2.title': {
en: 'Time limit per job',
'zh-CN': '单个任务时限'
},
'pricing.included.feature2.description': {
en: 'On our Standard and Creator plans, each workflow has a maximum run time of 30 minutes. On the Pro plan, the limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
'zh-CN':
'Standard 和 Creator 计划下,每个工作流最长运行时间为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
},
'pricing.included.feature3.title': {
en: 'Usage',
'zh-CN': '用量计费'
},
'pricing.included.feature3.description': {
en: "You're only charged for <strong>active GPU</strong> time while a workflow is running. Idle time (e.g. time spent building workflows) does not consume GPU hours.",
'zh-CN':
'仅在工作流运行期间按<strong>实际 GPU</strong> 使用时长计费。空闲时间(如构建工作流)不消耗 GPU 时长。'
},
'pricing.included.feature4.title': {
en: 'Credit balance',
'zh-CN': '积分余额'
},
'pricing.included.feature4.description': {
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
'zh-CN':
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro。'
},
'pricing.included.feature5.title': {
en: 'Add more credits anytime',
'zh-CN': '随时加购积分'
},
'pricing.included.feature5.description': {
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
'zh-CN':
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
},
'pricing.included.feature6.title': {
en: 'Pre-installed models',
'zh-CN': '预装模型'
},
'pricing.included.feature6.description': {
en: 'Access a library of 900+ pre-installed models.',
'zh-CN': '可访问 900+ 预装模型库。'
},
'pricing.included.feature7.title': {
en: 'Custom nodes support',
'zh-CN': '自定义节点支持'
},
'pricing.included.feature7.description': {
en: "Comfy Cloud currently supports a variety of the most-used custom nodes from the ComfyUI community. We're expanding support regularly based on demand and compatibility.",
'zh-CN':
'Comfy Cloud 目前支持 ComfyUI 社区中最常用的多种自定义节点,并根据需求和兼容性持续扩展支持范围。'
},
'pricing.included.feature8.title': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',
'zh-CN': '任务队列'
},
'pricing.included.feature9.description': {
en: 'Queue up to 100 workflows at once.',
'zh-CN': '可同时排队最多 100 个工作流。'
},
'pricing.included.feature10.title': {
en: 'Custom LoRA importing',
'zh-CN': '自定义 LoRA 导入'
},
'pricing.included.feature10.description': {
en: 'For those on the Creator or Pro plans, you can bring in your own models & LoRAs from CivitAI or Huggingface to perfect your own style.',
'zh-CN':
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA打造专属风格。'
},
'pricing.included.feature11.title': {
en: 'Parallel job execution',
'zh-CN': '并行任务执行'
},
'pricing.included.feature11.description': {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },
@@ -1212,10 +1314,10 @@ const translations = {
// ContactSection
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'gallery.contact.heading': {
en: 'Questions? Reach out!',
'zh-CN': '有精彩作品想要分享?\n联系我们。'
en: 'Built something cool with ComfyUI? <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
'zh-CN':
'用 ComfyUI 创作了很酷的作品?<a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
},
'gallery.contact.cta': { en: 'Contact us', 'zh-CN': '联系我们' },
// AboutHeroSection
'about.hero.label': { en: 'ABOUT', 'zh-CN': '关于' },
@@ -1353,7 +1455,7 @@ const translations = {
},
// CareersHeroSection
'careers.hero.label': { en: 'CAREERS', 'zh-CN': 'CAREERS' },
'careers.hero.label': { en: 'CAREERS', 'zh-CN': '招聘' },
'careers.hero.heading': {
en: 'Building an operating\nsystem for Gen AI',
'zh-CN': '构建生成式 AI 的\n\u201C操作系统\u201D'
@@ -1361,7 +1463,7 @@ const translations = {
'careers.hero.body1': {
en: "We're building the world's leading visual AI platform \u2014 open, modular, and designed for those who want control, quality and scale in their creative process.",
'zh-CN':
'我们全球领先的视觉 AI 平台\u2014\u2014一个开放、模块化的系统任何人都可以精确地构建、定制和自动化 AI 工作流,并拥有完全的控制权。'
'我们正在构建全球领先的视觉 AI 平台——开放、模块化,专为追求创作过程中的控制力、品质和规模化的人而设计。'
},
'careers.hero.body2': {
en: 'From solo creators to enterprise teams, millions of people rely on ComfyUI to push the boundaries of what creative AI can do.',
@@ -1402,6 +1504,10 @@ const translations = {
// CareersRolesSection
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
'careers.roles.empty': {
en: 'No open roles right now. Check back soon.',
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
},
// CareersFAQSection
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },
@@ -2078,6 +2184,13 @@ const translations = {
'zh-CN':
'从独立艺术家到全球工作室——构建视觉媒体未来的团队都在使用 ComfyUI。'
},
'customers.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'customers.contact.heading': {
en: 'Interested in a case study with ComfyUI? Reach out <a href="https://docs.google.com/forms/d/e/1FAIpQLSd-Keeq1VIePeanQIsdHq9eYeDE82MHJTdvwdgpxCoEzo_CUg/viewform" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">here</a>',
'zh-CN':
'有兴趣与 ComfyUI 合作案例研究?点击<a href="https://docs.google.com/forms/d/e/1FAIpQLSd-Keeq1VIePeanQIsdHq9eYeDE82MHJTdvwdgpxCoEzo_CUg/viewform" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">此处</a>联系我们'
},
'customers.story.series-entertainment.category': {
en: 'GAME & VIDEO PRODUCTION',
'zh-CN': '游戏与视频制作'
@@ -2130,6 +2243,20 @@ const translations = {
'zh-CN':
'育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。'
},
'customers.story.groove-jones.category': {
en: 'CASE STUDY',
'zh-CN': '案例研究'
},
'customers.story.groove-jones.title': {
en: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy",
'zh-CN':
"Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
},
'customers.story.groove-jones.body': {
en: 'Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline.',
'zh-CN':
'达拉斯创意工作室 Groove Jones 借助 Comfy在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。'
},
'customers.story.readMore': {
en: 'READ MORE ON THIS TOPIC',
'zh-CN': '阅读更多相关内容'
@@ -3163,6 +3290,227 @@ const translations = {
'zh-CN': 'ComfyUI 博客'
},
// Customer Detail: Groove Jones
// Topic 1: Intro
'customers.detail.groove-jones.topic-1.label': {
en: 'INTRO',
'zh-CN': '简介'
},
'customers.detail.groove-jones.topic-1.block.0': {
en: 'Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick\u2019s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.',
'zh-CN':
'位于达拉斯的创意工作室 Groove Jones为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick\u2019s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL他们反复遇到同一个挑战用商业项目的工期和预算交付电影级的 VFX 质量。'
},
'customers.detail.groove-jones.topic-1.block.1': {
en: 'For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick\u2019s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.',
'zh-CN':
'在 Crocs x NFL 联名系列的节日上市项目中这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick\u2019s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。'
},
// Topic 2: The Output
'customers.detail.groove-jones.topic-2.label': {
en: 'THE OUTPUT',
'zh-CN': '交付成果'
},
'customers.detail.groove-jones.topic-2.title': {
en: 'The Output Groove Jones Achieved Using Comfy',
'zh-CN': 'Groove Jones 借助 Comfy 实现的交付成果'
},
'customers.detail.groove-jones.topic-2.block.0': {
en: 'A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline\nHyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick\u2019s Sporting Goods parking lots\nVertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts\nSame-day iteration on client notes instead of week-long asset updates\nWinner, Aaron Awards 2024: Best AI Workflow for Production',
'zh-CN':
'在紧迫的节日档期内交付完整的 FOOH虚构户外广告社媒营销活动\n超写实视频巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick\u2019s Sporting Goods 停车场\n面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物\n客户反馈当天迭代不再需要数周的资产更新周期\n荣获 2024 年 Aaron Awards最佳 AI 制作工作流奖'
},
// Topic 3: The Problem
'customers.detail.groove-jones.topic-3.label': {
en: 'THE PROBLEM',
'zh-CN': '挑战'
},
'customers.detail.groove-jones.topic-3.title': {
en: 'The Problem Groove Jones Was Trying to Solve',
'zh-CN': 'Groove Jones 试图解决的问题'
},
'customers.detail.groove-jones.topic-3.block.0': {
en: 'A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team\u2019s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.',
'zh-CN':
'按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作每支球队鞋款的高精建模、look development、灯光、渲染、合成客户每次想要新变体都要重新渲染。这也意味着庞大的团队建模师、纹理师、灯光师、合成师以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。'
},
// Topic 4: How Comfy Solved the Problem
'customers.detail.groove-jones.topic-4.label': {
en: 'HOW COMFY SOLVED THE PROBLEM',
'zh-CN': 'Comfy 如何解决问题'
},
'customers.detail.groove-jones.topic-4.title': {
en: 'How Groove Jones Used Comfy to Solve the Problem',
'zh-CN': 'Groove Jones 如何用 Comfy 解决问题'
},
'customers.detail.groove-jones.topic-4.block.0': {
en: 'Groove Jones\u2019s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy\u2019s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.',
'zh-CN':
'Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。'
},
'customers.detail.groove-jones.topic-4.block.1.text': {
en: 'Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.',
'zh-CN':
'Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。'
},
'customers.detail.groove-jones.topic-4.block.1.name': {
en: 'Doug Hogan | Senior Creative Technologist @ Groove Jones',
'zh-CN': 'Doug Hogan | Groove Jones 高级创意技术总监'
},
// Topic 5: Brand-Trained LoRAs
'customers.detail.groove-jones.topic-5.label': {
en: 'BRAND-TRAINED LORAS',
'zh-CN': '品牌定制 LORA'
},
'customers.detail.groove-jones.topic-5.title': {
en: 'Brand-Trained LoRAs for Hero Assets',
'zh-CN': '为主视觉资产定制的品牌 LoRA'
},
'customers.detail.groove-jones.topic-5.block.0': {
en: 'Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick\u2019s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.',
'zh-CN':
'Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick\u2019s Sporting Goods 门店外景训练了定制 LoRA让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致不需要逐帧修正——而这通常意味着数周的 look development 工作量。'
},
'customers.detail.groove-jones.topic-5.block.1.src': {
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp',
'zh-CN':
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp'
},
'customers.detail.groove-jones.topic-5.block.1.alt': {
en: 'Grid of brand-accurate NFL team Crocs generated via custom LoRAs',
'zh-CN': '通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格'
},
'customers.detail.groove-jones.topic-5.block.1.caption': {
en: 'Brand-accurate NFL team colorways generated through custom LoRAs.',
'zh-CN': '通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。'
},
// Topic 6: Multi-Model Orchestration
'customers.detail.groove-jones.topic-6.label': {
en: 'MULTI-MODEL ORCHESTRATION',
'zh-CN': '多模型编排'
},
'customers.detail.groove-jones.topic-6.title': {
en: 'Multi-Model Orchestration in a Single Graph',
'zh-CN': '单张图内的多模型编排'
},
'customers.detail.groove-jones.topic-6.block.0': {
en: 'The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley\u2019s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.',
'zh-CN':
'这个创意在不同阶段需要不同的生成模型Flux 用于关键帧静帧开发Gemini Flash 2.5Nano Banana用于快速构思和变体生成Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。'
},
'customers.detail.groove-jones.topic-6.block.1.text': {
en: 'The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.',
'zh-CN':
'Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。'
},
'customers.detail.groove-jones.topic-6.block.1.name': {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
// Topic 7: The Pipeline
'customers.detail.groove-jones.topic-7.label': {
en: 'THE PIPELINE',
'zh-CN': '流水线'
},
'customers.detail.groove-jones.topic-7.title': {
en: 'Storyboards to Previz to Final Shot in One Pipeline',
'zh-CN': '从故事板到 Previz 再到成片,全部在一条流水线内'
},
'customers.detail.groove-jones.topic-7.block.0': {
en: 'The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.',
'zh-CN':
'工作流从传统故事板开始用于叙事确认,再进入 CGI blocking锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。'
},
'customers.detail.groove-jones.topic-7.block.1.src': {
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp',
'zh-CN':
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp'
},
'customers.detail.groove-jones.topic-7.block.1.alt': {
en: 'Storyboard grid for the Crocs x NFL holiday campaign',
'zh-CN': 'Crocs x NFL 节日营销的故事板网格'
},
'customers.detail.groove-jones.topic-7.block.1.caption': {
en: 'Grayscale storyboards used to lock narrative beats before generation.',
'zh-CN': '在生成之前用于锁定叙事节奏的灰度故事板。'
},
'customers.detail.groove-jones.topic-7.block.2.src': {
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp',
'zh-CN':
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp'
},
'customers.detail.groove-jones.topic-7.block.2.alt': {
en: 'Composition progression from blocking to mid-render to final shot',
'zh-CN': '从 blocking 到中间渲染再到最终镜头的构图演进'
},
'customers.detail.groove-jones.topic-7.block.2.caption': {
en: 'Composition progression: wireframe blocking, mid-render, and final shot.',
'zh-CN': '构图演进:线框 blocking、中间渲染、最终成片。'
},
// Topic 8: Version Control
'customers.detail.groove-jones.topic-8.label': {
en: 'VERSION CONTROL',
'zh-CN': '版本管理'
},
'customers.detail.groove-jones.topic-8.title': {
en: 'Workflow Files as Version Control',
'zh-CN': '把工作流文件当作版本管理'
},
'customers.detail.groove-jones.topic-8.block.0': {
en: 'Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.',
'zh-CN':
'每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。'
},
// Topic 9: Finishing in Nuke
'customers.detail.groove-jones.topic-9.label': {
en: 'FINISHING IN NUKE',
'zh-CN': 'Nuke 终修'
},
'customers.detail.groove-jones.topic-9.title': {
en: 'Finishing in Nuke',
'zh-CN': '在 Nuke 中完成终修'
},
'customers.detail.groove-jones.topic-9.block.0': {
en: 'Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.',
'zh-CN':
'生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。'
},
// Topic 10: The Takeaway
'customers.detail.groove-jones.topic-10.label': {
en: 'THE TAKEAWAY',
'zh-CN': '总结'
},
'customers.detail.groove-jones.topic-10.title': {
en: 'Conclusion',
'zh-CN': '结语'
},
'customers.detail.groove-jones.topic-10.block.0': {
en: 'By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.',
'zh-CN':
'通过在 Comfy 中搭建整套 FOOH 流水线Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。'
},
'customers.detail.groove-jones.topic-10.block.1.text': {
en: 'At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.',
'zh-CN':
'在 Groove Jones我们非常在意交付让人说"WOW"的作品但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃Comfy 帮我们彻底解决了这个问题。'
},
'customers.detail.groove-jones.topic-10.block.1.name': {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
'customers.detail.groove-jones.topic-10.block.2.label': {
en: 'GROOVE JONES CONTRIBUTORS',
'zh-CN': 'GROOVE JONES 贡献者'
},
'customers.detail.groove-jones.topic-10.block.2.name': {
en: 'TBD',
'zh-CN': '待补充'
},
'customers.detail.groove-jones.topic-10.block.2.role': {
en: 'TBD',
'zh-CN': '待补充'
},
// Contact FormSection
'contact.form.badge': {
en: 'CONTACT SALES',
@@ -3185,82 +3533,13 @@ const translations = {
en: 'Find your answer here',
'zh-CN': '在这里找到答案'
},
'contact.form.firstName': {
en: 'First name',
'zh-CN': ''
'contact.form.embedLoadErrorPrefix': {
en: 'Unable to load the contact form. Email us at',
'zh-CN': '联系表单无法加载。请发送邮件至'
},
'contact.form.lastName': {
en: 'Last Name',
'zh-CN': ''
},
'contact.form.company': {
en: 'Company',
'zh-CN': '公司'
},
'contact.form.phone': {
en: 'Phone Number (optional)',
'zh-CN': '电话号码(可选)'
},
'contact.form.packageQuestion': {
en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?',
'zh-CN':
'您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K、个人套餐或团队套餐'
},
'contact.form.packageIndividual': {
en: 'INDIVIDUAL',
'zh-CN': '个人'
},
'contact.form.packageTeams': {
en: 'TEAMS',
'zh-CN': '团队'
},
'contact.form.packageEnterprise': {
en: 'ENTERPRISE',
'zh-CN': '企业'
},
'contact.form.usingComfy': {
en: 'Are you /your team currently using Comfy?',
'zh-CN': '您/您的团队目前是否在使用 Comfy'
},
'contact.form.usingYesProduction': {
en: 'Yes, in production',
'zh-CN': '是,在生产环境中'
},
'contact.form.usingYesTesting': {
en: 'Yes, testing / experimenting',
'zh-CN': '是,测试/实验中'
},
'contact.form.usingNotYet': {
en: 'Not yet, evaluating',
'zh-CN': '尚未使用,评估中'
},
'contact.form.usingOtherTools': {
en: 'Not using Comfy yet, but using other GenAI tools',
'zh-CN': '尚未使用 Comfy但在使用其他 GenAI 工具'
},
'contact.form.lookingFor': {
en: 'What are you looking for?',
'zh-CN': '您在寻找什么?'
},
'contact.form.lookingForPlaceholder': {
en: 'Tell us about your team needs, expected usage, or other specific requirements.',
'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。'
},
'contact.form.submit': {
en: 'SUBMIT',
'zh-CN': '提交'
},
'contact.form.firstNamePlaceholder': {
en: 'Jane',
'zh-CN': 'Jane'
},
'contact.form.lastNamePlaceholder': {
en: 'Smith',
'zh-CN': 'Smith'
},
'contact.form.companyPlaceholder': {
en: 'jane@acme.org',
'zh-CN': 'jane@acme.org'
'contact.form.embedLoadErrorSuffix': {
en: "and we'll route your request.",
'zh-CN': '我们会为您处理请求。'
},
'customers.story.whatsNext': {

View File

@@ -17,7 +17,7 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props
@@ -57,7 +57,7 @@ const websiteJsonLd = {
---
<!doctype html>
<html lang={locale}>
<html lang={locale} class="overflow-x-clip">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -133,9 +133,15 @@ const websiteJsonLd = {
<script>
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
import { ScrollTrigger } from '../scripts/gsapSetup'
import { initPostHog, capturePageview } from '../scripts/posthog'
initSmoothScroll()
if (import.meta.env.PROD) {
initPostHog()
document.addEventListener('astro:page-load', capturePageview)
}
document.addEventListener('astro:page-load', () => {
ScrollTrigger.refresh()
})

View File

@@ -6,18 +6,40 @@ import BaseLayout from '../layouts/BaseLayout.astro'
title="404 — Page Not Found — Comfy"
noindex>
<div
class="grid w-full grid-rows-1 place-items-center p-4 h-[calc(100dvh-12rem)]">
<div
class="relative flex size-full min-h-0 items-center justify-center overflow-hidden">
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">
<div class="flex shrink-0 flex-col items-center gap-3 text-center">
<div class="w-[220px] max-w-[60%]">
<svg
viewBox="0 0 311 63"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="404 Page Not Found"
class="block h-auto w-full">
<path d="M11.249 63H29V0C20.8428 0 12.42 6.46924 10.1666 14.4406L0.55317 48.5594C-1.6903 56.5308 3.09184 63 11.249 63Z" fill="#F2FF59"/>
<rect width="65" height="63" transform="translate(29)" fill="#F2FF59"/>
<path d="M45.2357 16.4576V37.3232H47.6171V41.141H45.2357V47H40.6619V41.141H30.1157V36.9074L40.8887 16.4576H45.2357ZM33.5933 37.3232H40.6619V23.9042L33.5933 37.3232ZM61.5204 47.378C55.6614 47.378 52.7886 43.7492 52.7886 37.2854V26.0966C52.7886 19.6706 55.6614 16.0796 61.5204 16.0796C67.3416 16.0796 70.1766 19.6706 70.1766 26.1344V37.3232C70.1766 43.7492 67.3416 47.378 61.5204 47.378ZM57.4758 37.0964L65.2626 22.8458C64.809 20.7668 63.5238 19.8596 61.5204 19.8596C58.8744 19.8596 57.4758 21.3716 57.4758 25.0004V37.0964ZM57.7782 40.9898C58.3452 42.8042 59.5926 43.598 61.5204 43.598C64.1286 43.598 65.4894 42.086 65.4894 38.4194V26.8526L57.7782 40.9898ZM90.321 16.4576V37.3232H92.7024V41.141H90.321V47H85.7472V41.141H75.201V36.9074L85.974 16.4576H90.321ZM78.6786 37.3232H85.7472V23.9042L78.6786 37.3232Z" fill="#211927"/>
<path d="M113.15 48.58L116.26 37.73C116.89 35.52 118.92 34 121.21 34C124.63 34 127.1 37.26 126.17 40.55L125.75 42.06C124.36 46.99 127.32 51 132.37 51H142.85V12.01C137.96 12.27 133.05 16.17 131.7 20.94L130.58 24.9C129.96 27.11 127.95 28.63 125.66 28.63C122.26 28.63 119.8 25.37 120.74 22.1L122.94 14.42C125.22 6.46 120.36 0 112.07 0H94V63C102.28 63 110.85 56.54 113.13 48.58H113.15Z" fill="#F2FF59"/>
<rect width="150" height="39" transform="translate(142.85 12)" fill="#F2FF59"/>
<path d="M144.516 41V22.0928H149.687C153.665 22.0928 155.21 23.7308 155.21 27.9662V28.5746C155.21 32.7866 153.689 34.5416 149.687 34.5416H147.371V41H144.516ZM147.371 32.2952H149.921C151.653 32.2952 152.285 31.4528 152.285 29.4404V27.1004C152.285 25.1114 151.653 24.3392 149.921 24.3392H147.371V32.2952ZM154.605 41L158.326 22.0928H162.257L165.977 41H163.076L162.467 37.49H158.115L157.53 41H154.605ZM158.536 35.0798H162.07L160.361 25.1114L160.291 24.8072L160.221 25.088L158.536 35.0798ZM171.55 41.234C168.204 41.234 166.332 38.8472 166.332 35.1266V28.0832C166.332 24.2456 168.134 21.8588 171.901 21.8588C175.482 21.8588 177.33 24.0116 177.4 27.5918V29.417H174.499L174.475 26.9132C174.429 24.9476 173.446 24.2222 171.925 24.2222C170.1 24.2222 169.234 25.2518 169.234 27.3812V35.6882C169.234 37.7942 170.053 38.8706 171.878 38.8706C173.586 38.8706 174.405 37.958 174.475 36.0158V33.512H171.527V31.289H177.4V41H175.926L175.575 39.1982C174.733 40.4384 173.446 41.234 171.55 41.234ZM179.103 41V22.0928H187.808V24.503H181.958V30.353H187.48V32.7398H181.958V38.5898H187.808V41H179.103ZM194.026 41V22.0928H196.647L202.099 34.7054V22.0928H204.72V41H202.076L196.647 28.3874V41H194.026ZM211.848 41.234C208.104 41.234 206.279 38.8472 206.279 35.0096V28.0832C206.279 24.2456 208.104 21.8588 211.848 21.8588C215.592 21.8588 217.44 24.2456 217.44 28.0832V35.0096C217.44 38.8472 215.592 41.234 211.848 41.234ZM209.18 35.6882C209.18 37.7942 210.069 38.8706 211.848 38.8706C213.626 38.8706 214.515 37.7942 214.515 35.6882V27.428C214.515 25.322 213.626 24.2222 211.848 24.2222C210.069 24.2222 209.18 25.2986 209.18 27.4046V35.6882ZM221.265 41V24.503H217.544V22.0928H227.864V24.503H224.12V41H221.265ZM233.47 41V22.0928H242.245V24.503H236.324V31.2422H242.011V33.629H236.324V41H233.47ZM248.572 41.234C244.828 41.234 243.003 38.8472 243.003 35.0096V28.0832C243.003 24.2456 244.828 21.8588 248.572 21.8588C252.316 21.8588 254.165 24.2456 254.165 28.0832V35.0096C254.165 38.8472 252.316 41.234 248.572 41.234ZM245.905 35.6882C245.905 37.7942 246.794 38.8706 248.572 38.8706C250.351 38.8706 251.24 37.7942 251.24 35.6882V27.428C251.24 25.322 250.351 24.2222 248.572 24.2222C246.794 24.2222 245.905 25.2986 245.905 27.4046V35.6882ZM260.97 41.234C257.249 41.234 255.634 39.3854 255.634 35.4776V22.0928H258.536V36.0626C258.536 38.2622 259.378 38.8706 260.97 38.8706C262.561 38.8706 263.427 38.2622 263.427 36.0626V22.0928H266.305V35.4776C266.305 39.4088 264.667 41.234 260.97 41.234ZM268.148 41V22.0928H270.769L276.221 34.7054V22.0928H278.842V41H276.198L270.769 28.3874V41H268.148ZM280.728 41V22.0928H285.619C289.62 22.0928 291.492 23.4968 291.492 28.0598V35.033C291.492 39.5492 289.62 41 285.619 41H280.728ZM283.583 38.7536H285.642C287.772 38.7536 288.567 38.0048 288.567 35.4542V27.6386C288.567 25.088 287.772 24.3392 285.619 24.3392H283.583V38.7536Z" fill="#211927"/>
<path d="M303.839 12H292.85V51C297.9 51 303.114 46.9952 304.509 42.0606L310.46 20.9394C311.849 16.0048 308.888 12 303.839 12Z" fill="#F2FF59"/>
</svg>
</div>
<p class="text-[15px] tracking-[0.02em] opacity-90">
Looks like this node went off-grid.
</p>
<p
class="font-mono text-sm tracking-[0.05em] opacity-75"
>Score: <span id="scoreNow">0</span><span
class="mx-2.5 opacity-50">|</span
>Best: <span id="scoreBest">0</span></p
>
</div>
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<svg
id="game"
class="block size-full"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet">
<use
class="pointer-events-none text-secondary-mauve"
href="/svg/404-title.svg#title404"
></use>
<g id="board"></g>
<g
fill="currentColor"
@@ -36,19 +58,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
class="cursor-pointer uppercase"
>CLICK TO START</text
>
<text
id="scoreLine"
x="300"
y="232"
font-family="'PP Formula', sans-serif"
font-size="20"
letter-spacing="0.12em"
text-anchor="end"
dominant-baseline="middle"
transform="rotate(-31 400 200)"
display="none"
class="uppercase"
></text>
</g>
</svg>

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/about/HeroSection.vue'
import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../components/about/CareersSection.vue'
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />
<ValuesSection client:visible />
<CareersSection />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../components/careers/RolesSection.vue'
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../components/common/FAQSection.vue'
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
>
<HeroSection />
<RolesSection client:visible />
<RolesSection departments={departments} client:visible />
<WhyJoinSection client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -4,6 +4,7 @@ import HeroSection from '../components/customers/HeroSection.vue'
import StorySection from '../components/customers/StorySection.vue'
import FeedbackSection from '../components/customers/FeedbackSection.vue'
import VideoSection from '../components/customers/VideoSection.vue'
import ContactSection from '../components/customers/ContactSection.vue'
---
<BaseLayout title="Customer Stories — Comfy">
@@ -11,4 +12,5 @@ import VideoSection from '../components/customers/VideoSection.vue'
<StorySection />
<FeedbackSection client:load />
<VideoSection client:load />
<ContactSection />
</BaseLayout>

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/about/HeroSection.vue'
import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../../components/about/CareersSection.vue'
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />
<ValuesSection locale="zh-CN" client:visible />
<CareersSection locale="zh-CN" />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../../components/careers/RolesSection.vue'
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../../components/common/FAQSection.vue'
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" client:visible />
<RolesSection locale="zh-CN" departments={departments} client:visible />
<WhyJoinSection locale="zh-CN" client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -5,12 +5,20 @@ import HeroSection from '../../../components/product/enterprise/HeroSection.vue'
import SocialProofBarSection from '../../../components/common/SocialProofBarSection.vue'
import ReasonSection from '../../../components/product/enterprise/ReasonSection.vue'
import TeamSection from '../../../components/product/enterprise/TeamSection.vue'
import DataOwnershipSection from '../../../components/product/enterprise/DataOwnershipSection.vue'
import BYOKeySection from '../../../components/product/enterprise/BYOKeySection.vue'
import OrchestrationSection from '../../../components/product/enterprise/OrchestrationSection.vue'
import ProductCardsSection from '../../../components/common/ProductCardsSection.vue'
---
<BaseLayout title="Comfy Enterprise">
<BaseLayout title="Comfy 企业版">
<CloudBannerSection locale="zh-CN" />
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ReasonSection locale="zh-CN" />
<TeamSection locale="zh-CN" />
<DataOwnershipSection locale="zh-CN" client:visible />
<BYOKeySection locale="zh-CN" />
<OrchestrationSection locale="zh-CN" client:visible />
<ProductCardsSection locale="zh-CN" exclude-product="enterprise" label-key="products.labelProducts" />
</BaseLayout>

View File

@@ -4,6 +4,7 @@ import HeroSection from '../../components/customers/HeroSection.vue'
import StorySection from '../../components/customers/StorySection.vue'
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
import VideoSection from '../../components/customers/VideoSection.vue'
import ContactSection from '../../components/customers/ContactSection.vue'
---
<BaseLayout title="客户故事 — Comfy">
@@ -11,4 +12,5 @@ import VideoSection from '../../components/customers/VideoSection.vue'
<StorySection locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />
<VideoSection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />
</BaseLayout>

View File

@@ -1,39 +1,12 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/gallery/HeroSection.vue'
import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
---
<BaseLayout title="作品集 — Comfy">
<div class="bg-black text-white">
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">
在 ComfyUI 中<span class="text-brand-yellow">构建、调整与梦想</span>
</h1>
<p class="mx-auto mt-4 max-w-2xl text-lg text-smoke-700">
社区使用 ComfyUI 创作的精彩作品一瞥。
</p>
</section>
<!-- Placeholder Grid -->
<section class="mx-auto max-w-6xl px-6 pb-24">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 6 }).map(() => (
<div class="flex aspect-video items-center justify-center rounded-xl border border-white/10 bg-charcoal-600">
<p class="text-sm text-smoke-700">社区展示即将上线</p>
</div>
))}
</div>
</section>
<!-- CTA -->
<section class="mx-auto max-w-3xl px-6 pb-32 text-center">
<h2 class="text-2xl font-semibold">有很酷的作品想分享?</h2>
<a
href="https://support.comfy.org/"
class="mt-6 inline-block rounded-full bg-brand-yellow px-8 py-3 font-medium text-black transition hover:opacity-90"
>
联系我们
</a>
</section>
</div>
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />
</BaseLayout>

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