Compare commits

..

18 Commits

Author SHA1 Message Date
jaeone94
905bc50129 fix: stop double-escaping special characters in error tab copy
Error catalog resolvers pass node and model names to vue-i18n's
t(key, params). Because escapeParameter is enabled globally, those
params are HTML-escaped, and the resolved strings are then rendered
through Vue text interpolation, which escapes again at the render
boundary. Names containing &, <, or > therefore surfaced as literal
&amp;, &lt;, &gt; in the error tab.

Pass escapeParameter: false at the two catalog translation call sites
so escaping happens exactly once, at the Vue render boundary. XSS
protection is unchanged since the render boundary still escapes.
2026-07-15 12:10:49 +09:00
Comfy Org PR Bot
5bf41a41bd 1.48.3 (#13606)
Patch version increment to 1.48.3

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-15 02:38:12 +00:00
CodeJuggernaut
a7f14a0b3f fix(subscription): size pricing dialogs with Reka props (#13633)
## Summary

Fixes the legacy personal and legacy workspace pricing dialogs so Reka
owns the dialog width and the pricing table no longer overflows the
default 576px frame.

## Changes

- **What**: Replace the shared PrimeVue-only `style` and `pt` dialog
props with Reka `renderer`, `size`, and `contentClass` props for both
legacy pricing paths.
- **What**: Preserve `modal: false` for the legacy workspace path so its
teleported PrimeVue plan-details popover remains interactive.
- **What**: Add unit coverage for both routes and assert that ignored
PrimeVue shell props are no longer passed.

## Review Focus

- **Regression origin**:
[#12593](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12593) made
Reka the default dialog renderer.
[#12666](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12666) then
added shared PrimeVue `style` and `pt` props for these pricing dialogs.
Reka ignored those props and fell back to `size="md"` (`max-w-xl`,
576px).
[#13092](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13092) fixed
the unified pricing path only and explicitly left the two legacy paths
for follow-up.
- **Sizing ownership**: The fix puts width on the Reka dialog frame with
`size: 'full'` and `sm:max-w-7xl`. Pricing content no longer has to
compensate for a narrow shell.
- **Legacy workspace behavior**: `modal: false` remains intentional
because the legacy table opens a PrimeVue popover teleported to `body`.
- **Scope**: This PR contains only global subscription-dialog sizing.
Agent side-panel behavior remains in
[#13472](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13472).
- **Validation**: Focused tests pass (41 tests), `pnpm typecheck`
passes, and targeted ESLint passes. Chrome validation at 1352x705
rendered a 1280px dialog with no horizontal overflow in both the
isolated PR preview and the combined agent-panel preview. The docked
agent panel remained mounted behind the modal.

## Screenshots (if applicable)

- Original report and screenshots: [Slack
thread](https://comfy-organization.slack.com/archives/C0A8Z4U7Y1K/p1783967281397449?thread_ts=1783966866.227439&cid=C0A8Z4U7Y1K)
2026-07-15 00:35:09 +00:00
Christian Byrne
e6d1a9d4a2 fix(release): support manual target-branch override + major versions in resolve-comfyui-release (#13660)
## Problem

`scripts/cicd/resolve-comfyui-release.ts` derived the release target
purely from ComfyUI's `requirements.txt` pin and **hardcoded major
version `1`** (`core/1.${minor}`, `v1.${minor}.*`,
`1.${minor}.${patch}`) even though it parsed `major` and never used it.
Consequences:

- Could not release an out-of-cadence branch (e.g. skip a dead 1.46 to
ship 1.47 directly).
- Could not do a major bump (2.x).

## Changes

1. **Resolver (`resolve-comfyui-release.ts`)** — uses the parsed
`targetMajor` (defaults to the current pin's major) for every
branch/tag/version string instead of literal `1`. `getLatestPatchTag`
now takes a `major` param and globs `v${major}.${minor}.*`.

2. **`TARGET_BRANCH` env override (highest precedence)** — when set,
validates `^core/(\d+)\.(\d+)$`, verifies `origin/<branch>` exists, and
skips the `RELEASE_TYPE`/pin-derived selection entirely. If both
`TARGET_BRANCH` and `RELEASE_TYPE` are set, the override wins.
`current_version` still comes from the pin (for `diff_url` and the
ComfyUI PR "from" version), so the requirements bump jumps straight from
the pin to `target_version` (e.g. 1.45.20 → 1.47.8, skipping 1.46).

3. **Workflow (`release-biweekly-comfyui.yaml`)** — new optional
`target_branch` `workflow_dispatch` input, wired into the resolve step's
`env` as `TARGET_BRANCH`, and surfaced in the run summary. Downstream
jobs consume `target_branch`/`target_version` outputs unchanged.

The output JSON shape is identical. Extracted pure helpers
(`parseTargetBranchOverride`, `computeTargetVersion`) and guarded the
main block so the module is importable by tests.

`release-version-bump.yaml` and `release-branch-create.yaml` were
**already** major-aware and are left untouched.

## Tests

New `scripts/cicd/resolve-comfyui-release.test.ts` (15 cases) covering
`parseRequirementsVersion` (==, >=, missing/absent), `isValidSemver`,
`parseTargetBranchOverride` (valid `core/1.47` and `core/2.0`, malformed
rejected), and `computeTargetVersion` including a non-1 major (`v2.0.3`
+ commits → `2.0.4`).

## Usage

```
gh workflow run release-biweekly-comfyui.yaml --field target_branch=core/1.47
```

releases 1.47.8 directly (skipping a dead 1.46), or `--field
target_branch=core/2.0` for a major bump.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:41:35 +00:00
Dante
0b4a960467 feat(billing): derive next-invoice amount, date, and cadence from billing state (FE-1245) (#13599)
## Summary

Adds the data seam for the Settings > Plan & Credits Invoices tab
(FE-1245): derives the next-invoice amount, date, and billing cadence
from billing state already fetched by the billing context, replacing the
prototype's hardcoded mock.

Updated for the 2026-07-13 design decisions (Slack thread + Willie's
Figma updates): the banner surfaces the BE-provided next-invoice date,
and annual subscriptions now show their yearly total and real renewal
date instead of hiding the banner.

## Changes

- **What**: `useNextInvoice` composable + pure `deriveNextInvoice` in
`src/composables/billing/` — returns `{ nextInvoice:
ComputedRef<NextInvoice | null> }`, `NextInvoice = { amountCents,
renewalDate, duration }`
- Monthly: subscribed team credit stop `monthly.price_cents` (status
`team_credit_stop.id` matched against the ladder) with plan
`price_cents` fallback — unchanged precedence
- Annual: stop `yearly.price_cents * 12` (stop yearly prices are
per-month figures, per `useWorkspacePlanPricing`) or the ANNUAL plan's
`price_cents` as-is (already the yearly total, per
`UnifiedPricingTable`)
- `renewalDate`: BE-computed `renewal_date` passed through untouched —
backends own period math including month-end bias. It goes null the
moment a cancellation is scheduled (mutually exclusive with
`end_date`/`cancel_at` on both billing backends)
- null (banner hidden) when inactive, cancelled, the amount is
unresolvable/non-positive (covers legacy billing's empty plan list and
free tier), or the plan resolved by slug disagrees with the
subscription's cadence
- 13 unit tests cover all branches, including x12-regression fixtures
with discriminated list/discount prices

Intentionally excludes usage/overage pending charges until the backend
exposes an authoritative upcoming-invoice amount (see FE-1245).

Follow-ups (not this PR):

- Cancelled-state toast (Figma 4617:29298 month-remaining / 4617:29992
terminal): separate seam UI work. Data is already available —
`subscription.endDate` is populated by both billing backends exactly
during the cancelled-but-paid window, and `useResubscribe()` covers the
Renew plan action
- "/year" amount framing is shown with placeholder copy; no annual
variant exists in the Figma file yet (design follow-up)

## Review Focus

- Annual unit semantics: stop `yearly.price_cents` is a per-month figure
(`useWorkspacePlanPricing.ts` `teamMonthlyCostCents`), while
`Plan.price_cents` for ANNUAL plans is the yearly total
(`UnifiedPricingTable.vue` `getAnnualTotal`) — hence x12 in one branch
and as-is in the other
- Consumed by the `useWorkspaceInvoices` seam once #13591 lands; the
swap now also requires the seam template to render
`renewalDate`/`duration`, so it is no longer a one-line body change
- FE-1245 / DES-497

## Screenshots

Live captures through the real `WorkspaceInvoicesContent` + this
composable with `/api/billing/*` mocked per state (isolated preview
harness). Date format follows the existing billing convention; "/ year"
framing is placeholder pending the annual design variant.

Active monthly subscription, team credit stop `team_320` — $320 USD with
next-invoice date:

![Invoices — team credit stop $320 with
date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-team-stop.png)

Active monthly subscription without a credit stop — plan-price fallback,
$20 USD:

![Invoices — plan price fallback $20 with
date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-plan-price.png)

Annual subscription — banner now shown: yearly total from the per-month
stop figure (288 x 12 = $3,456) and the real yearly renewal date:

![Invoices — annual
shown](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-annual.png)

Paused subscription — unchanged: next-invoice banner hidden, the paused
banner hosts the Full invoice history action (capture from the earlier
full-app harness):

![Invoices —
paused](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-paused.png)
2026-07-14 23:21:11 +00:00
Christian Byrne
060957d66c fix: use ComfyUI version for Cloud release notes (#13632)
## Summary

On Cloud, `releaseStore.currentVersion` sourced `cloud_version` (e.g.
`0.160.1`) while the `/releases` feed keys its entries by **ComfyUI**
version (e.g. `0.27.1`). The what's-new popup compares the latest feed
entry against the running version, so `0.27.1 < 0.160.1` read as
"already ahead of the latest release" and the popup never showed.

- Analytics confirmed the regression: `release_note` clicks fell from
**13.4% (90d) → 0% (30d)**; `cloud_release_note` was effectively never
clicked.

## Change

- `currentVersion` always uses `comfyui_version` (drops the `isCloud →
cloud_version` branch). The feed request keeps `project: 'cloud'`.
- Rationale: the changelog page and each note's "learn more" link are
ComfyUI-versioned, and Cloud has no separate versioned feed or changelog
page. The changelog is maintained with ComfyUI versions, updated after
each Cloud deploy lands.

## Tests

- Adds a regression test (`isCloud environment (FE-1237)`) pinning that
Cloud uses `comfyui_version`, not `cloud_version`. Verified via negative
control: reverting the fix fails it with `0.160.1` vs expected `0.27.1`.
- `test:unit` (releaseStore): 49 passed · `typecheck`, `lint`,
`format:check`, `knip`: clean.

## ADR

Adds `docs/adr/0012-cloud-release-notes-use-comfyui-version.md`
(Accepted) recording the rationale and history, and updates the ADR
index.

Fixes FE-1237

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:03:58 +00:00
Christian Byrne
1a98362984 fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631)
## Summary

Defense-in-depth fix for the `Invalid linked format` crash: escape
vue-i18n message-syntax characters at node-def generation so the locale
bundle is always valid, **and** harden the runtime so any compile error
(custom nodes, already-shipped bundles) degrades to the raw message
instead of crashing the app.

This folds in the runtime guard from #13603 (@xmarre) — see credit below
— so the two layers land together.

## Changes

**Layer 1 — generation-time escaping (source data)**
- Add `escapeVueI18nMessageSyntax()` to
`packages/shared-frontend-utils/src/formatUtil.ts`. It escapes every
character vue-i18n's message compiler treats as syntax in text — `@ { }
| %` (verified against `@intlify/message-compiler`'s `readText`
tokenizer): `@` and unbalanced `{`/`}` throw, `|` mis-renders as plural
branches, `%` matters immediately before `{`. Each becomes a literal
interpolation `{'x'}` (the only escape vue-i18n supports); the set isn't
exported by the library so it's hardcoded with source/doc references.
Single-pass, applied once.
- Apply it in `scripts/collect-i18n-node-defs.ts` to the fields
**compiled** via `t()`/`st()`: `display_name`, `description`,
input/output `name`, widget labels, `dataTypes`, `nodeCategories`.
Tooltip fields go through `tm()`/`stRaw()` (no compile step) and are
intentionally left unescaped.
- Regression test (`src/locales/escapeNodeDefI18n.test.ts`) compiles the
escaped output for all five characters through real vue-i18n and asserts
it round-trips verbatim.

**Layer 2 — runtime fallback (consumer), folded in from #13603 by
@xmarre**
- `st()` now wraps `t(key)` in try/catch: a vue-i18n `SyntaxError` falls
back to the raw locale message (`tm()`) instead of crashing bootstrap.
Shared `rawTranslationOrFallback` helper reused by `stRaw()`. Tests in
`src/i18n.safeTranslation.test.ts` (isolated per-test, and covering the
non-`SyntaxError` rethrow branch).

**Tooling**
- Lint the collection scripts instead of ignoring them: import the
shared helper via the `@/utils/formatUtil` tsconfig alias (fixes
`import-x/no-relative-packages`; verified it resolves under Playwright)
and drop a stray progress `console.log`. No lint config changes and no
rules relaxed — the scripts are now genuinely linted (they were
previously excluded).

- **Breaking**: none.

## Review Focus

- **Root cause**: values read via `t()`/`st()` are compiled by vue-i18n,
which parses `@ { } | %` as message syntax; a malformed `@` throws
`Invalid linked format`. `app.ts` translates every node description at
startup, so one bad string aborts router load. Surfaced by the 1.47.7
locale sync (#13246), whose `ByteDanceSeedAudio` description contains
`@Audio1-3`.
- **The two layers compose**: escaped output compiles cleanly, so the
runtime catch never fires for generated strings; the catch is the safety
net for strings that bypass generation (custom/third-party nodes via
`mergeCustomNodesI18n`, and locale bundles already shipped in
1.47.x/1.48.x).
- **Compiled vs. raw boundary**: escaping is applied only to
compiler-consumed fields; leaving tooltips raw matches the
`stRaw()`/`tm()` work in #12469.
- **Translation propagation**: lobe-i18n's config prompt already keeps
`{...}` placeholders intact, so the escaped English propagates to all 13
locales.
- **Follow-up (out of scope)**: `scripts/collect-i18n-general.ts`
(commands/settings/menus) shares the same generation-time hazard and
could reuse the same helper later; Layer 2 already covers it at runtime.

## Credit

The runtime fallback (Layer 2) is @xmarre's work from #13603, folded in
here with authorship preserved on the original commits. Closing #13603
in favor of this combined PR.

Fixes #13545
Fixes #13575

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
2026-07-14 18:14:47 +00:00
Terry Jia
ab33746b3e fix(load3d): letterbox-aware pointer NDC for the transform gizmo (#13550)
## Summary
The viewport letterboxes the scene to the width/height target aspect,
but TransformControls maps pointers over the full canvas rect, so its
handle raycasts skew toward the center, increasingly wrong toward the
edges whenever the canvas aspect differs from the target aspect.

Adds clientPointToLetterboxNdc() (pure, unit-tested) + a
Viewport3d.clientPointToNdc() wrapper, and routes TransformControls'
pointer math through it via GizmoManager.setPointerNdcSource(). 
Points on the letterbox bars resolve to nothing instead of phantom
handles.

## Screenshots (if applicable)
before
<img width="813" height="703" alt="image"
src="https://github.com/user-attachments/assets/cf5714c3-6b60-4ced-84ce-039f49c7e999"
/>
<img width="673" height="696" alt="image"
src="https://github.com/user-attachments/assets/31f898d2-a820-4748-b960-4f3cc5752095"
/>

after
<img width="1391" height="981" alt="image"
src="https://github.com/user-attachments/assets/18890d89-ff50-4298-bac5-472e00c79e24"
/>
<img width="955" height="712" alt="image"
src="https://github.com/user-attachments/assets/255f3c7d-dbb5-40a8-8865-c023caa1384e"
/>
2026-07-14 13:39:03 -04:00
Benjamin Lu
55c4e807a1 fix: use system hour cycle for queue times (#12297)
## Summary

Use the selected app locale for queue timestamp language and punctuation
while honoring the browser/system 12-hour or 24-hour clock preference.

## Changes

- **What**: `formatClockTime` resolves the hour cycle from the
browser/runtime default locale, then applies it while formatting with
the app locale.
- **What**: Added behavioral unit coverage for the production
two-argument path and explicit 12-hour and 24-hour preference locales.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Queue timestamps already use the browser's local timezone. This PR fixes
only the remaining clock-preference mismatch: app language stays in
control of the rendered time string, while the browser/system locale
controls 12-hour versus 24-hour display.

No Playwright regression test was added because browser/system
hour-cycle preference is environment-level Intl state, not deterministic
UI state that the E2E suite can set reliably. Unit tests pin both hour
cycles through BCP-47 locale preferences and assert the rendered
behavior directly.

Fixes
[FE-252](https://linear.app/comfyorg/issue/FE-252/bug-queue-progress-times-ignore-browsersystem-1224-hour-preference)

## Screenshots (if applicable)

Not applicable.

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-14 16:58:25 +00:00
jaeone94
74147d7ee2 feat: lift boundary-exposed validation errors to the subgraph host (#13542)
## Summary

Backend validation errors (`node_errors`) are reported against the
**flattened** prompt, so an error whose real fix lives on a subgraph
host node gets attached to an interior node the user may never open —
and, after ADR 0009, often *cannot* meaningfully fix there. This PR
re-surfaces a validation error onto the subgraph host node **when — and
only when — the error's subject (the specific input/widget named by
`extra_info.input_name`) is exposed through the subgraph boundary**.

## Why this is needed

Two concrete situations motivated this, both observed in real workflows:

1. **Broken link at the host.** Root node A should feed subgraph host B,
whose boundary input is linked to interior node C. If the A→B link is
missing, the backend flattens the prompt, sees C with no resolved input,
and raises `required_input_missing` **on C** (`"B:C"`). The actual fix —
connect B's input — is one level up, on a node the error never points
at.
2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets
use linked inputs), a promoted widget's value is owned by the host
`SubgraphNode`; the interior widget only supplies schema/defaults. When
the backend raises `value_not_in_list` (or min/max violations) for that
value, attributing it to the interior node is factually wrong — the
value that failed validation *is the host's value*.

This continues the direction of #13059, which moved **missing-model**
detection identity to `{hostExecutionId, hostWidgetName}` with the
interior path kept as diagnostics. That was possible in the FE pre-scan;
this PR applies the same ownership principle to **backend-received**
errors via a receive-side mapping, since the backend cannot know about
subgraph boundaries in a flattened prompt.

## The rule (design)

> Lift an error from interior node N to host H **iff** N's input slot
named by the error is linked to the containing subgraph's boundary
(`SubgraphInput`). Apply the same test again at H (boundary-by-boundary,
matching ADR 0009's chaining principle) and stop at the first level
where the subject is no longer boundary-linked — that node is where the
user can actually fix it.

The predicate is **structural (boundary exposure), not data-flow**.

### In scope — examples

- `required_input_missing` on interior `"12:5"` whose input is fed by
the boundary → surfaces on host `12`'s input slot (red slot ring on the
host, errors-tab card titled/located at the host, message names the
host's `SubgraphInput.name`).
- `value_not_in_list` / `value_smaller_than_min` /
`value_bigger_than_max` on a promoted interior widget → surfaces on the
host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`,
and further to `"1"` only if `1:2`'s own slot is boundary-linked too.
- Clearing follows the surface: connecting the highlighted host input or
fixing the host widget clears the underlying interior (raw) error —
range-guarded per target, so a still-out-of-range host value does
**not** clear.

### Out of scope — examples

- **No value-flow ancestry.** All in the root graph: A's widget links to
B, B's to C, and C rejects the value that originated at A → the error
**stays on C**. Following same-graph links to a "root cause" node is
explicitly not this feature.
- Errors without an `input_name` subject, node-level types
(`exception_during_validation`, `dependency_cycle`, image-not-loaded),
and unknown validation types — never lifted. Unknown types stay
node-scoped to match how the error catalog renders them (the shared
`isNodeLevelValidationError` in `executionErrorUtil` encodes this, and
the catalog derives its node-level rules from the same set).
- Runtime execution errors (exceptions during a run) — validation
responses only.
- Interior errors whose input is fed by another interior node — fixable
in place, stay in place.
- Fan-out display dedupe: when one boundary input feeds multiple
interior nodes and the host slot is unconnected, each interior error
lifts to the same host slot as a separate panel line. A single fix
(connecting the host input) clears all of them — the clearing
translation already fans out — so the duplication is cosmetic.
Display-level dedupe is a follow-up; deduping inside the lift would
break the one-source-per-error clearing contract.
- Reactive re-lifting on graph topology edits while errors are displayed
(invariant documented on the computed; follow-up), and deriving the
error catalog's full validation rule table from the shared
classification (follow-up; the node-level type set and the
image-not-loaded predicate are already single-sourced in
`executionErrorUtil` and consumed by both the lift and the catalog).

## Changes

- **What**: New pure module
`core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open
record transform (unresolvable ids/slots/links leave the error where the
backend put it; raw payload is never mutated). `executionErrorStore`
derives `surfacedNodeErrors` from it and display consumers switch over
(errors tab grouping, canvas node/slot flags, Vue node badges,
app-mode/linear hints); raw `lastNodeErrors` remains the source of truth
for mutation. Host-side clearing translates through the lift's
diagnostics fields (`source_execution_id` / `source_input_name`) with a
per-target range guard.
- **Breaking**: None. No persistence/serialization changes; interior
identity survives as diagnostics metadata only (ADR 0009 language).

## Review Focus

- The lift predicate lives entirely on link topology
(`LLink.originIsIoNode` → `SubgraphInput`) — no
`proxyWidgets`/promotion-store style source authority is reintroduced.
- `clearSlotErrorsWithRangeCheck` now resolves clear targets first and
range-checks each target's raw errors; the lifted-path twin of the
existing range-retention test pins this.
- `useProcessedWidgets` deliberately stays on the raw record: host
promoted widgets already map to interior errors via
`widget.sourceExecutionId`, so an interior widget keeps its local red
hint when the user opens the subgraph (hint layer vs surface layer).
- Unit coverage is carried by the pure module (real litegraph subgraph
fixtures, incl. nested recursion, promoted widgets via
`promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation);
one e2e pins the user-visible contract (host ring + host slot dot +
interior clean).

## Screenshots

### Before 


https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
Benjamin Lu
16fdcab4be feat(telemetry): track signup/login flow errors in PostHog (#13504)
## Summary

Auth error toasts in the signup/login flow (e.g. "No account found with
this email…") were never tracked, leaving no analytics visibility into
signup dropoff. This adds a PostHog event for them.

## Changes

- **What**: New `app:user_auth_failed` PostHog event with `{ error_code,
auth_action }`, fired from a `reportAuthFlowError` wrapper in
`useAuthActions` before the existing error toast. Wired to the seven
auth-flow actions (`email_sign_in`, `email_sign_up`, `google_sign_in`,
`google_sign_up`, `github_sign_in`, `github_sign_up`, `password_reset`);
covers both the cloud login/signup pages and the in-app sign-in dialog
since all funnel through the same handler. Non-auth actions (logout,
billing) intentionally stay untracked.

## Review Focus

- PostHog provider only — not added to Mixpanel/GTM/CustomerIo/etc.
- No PII: only the Firebase error code is sent (`'unknown'` for
non-Firebase errors), never the message or email.
- The `useAuthActions` test mock of `wrapWithErrorHandlingAsync`
previously rethrew errors; the real implementation swallows them after
handling. The mock now matches the real contract, and one pre-existing
logout assertion that depended on the rethrow was updated.

Fixes
[MAR-451](https://linear.app/comfyorg/issue/MAR-451/add-tracking-for-signup-flow-errors)

---------

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-07-13 23:04:58 +00:00
Mobeen Abdullah
96c6dbcb69 fix(website): replace em dash meta title separators with hyphens (#13628)
## Summary

Switch the meta title separator sitewide from an em dash (—) to a spaced
hyphen ( - ), the follow-up to #13480 tracked as FE-1233, based on the
Screaming Frog crawl that flagged the em dash titles.

## Changes

- **What**: Replaced the em dash separator with a hyphen in every page
meta title, in both `en` and `zh-CN`. Covers ~44 inline page titles, the
shared title keys in `i18n/translations.ts` (`cloudNodes.meta.title`,
`mcp.meta.title`, `affiliate-terms.page.title`,
`enterprise-msa.page.title`, `affiliate.page.title`,
`launches.page.title`), and the dynamic `customers`, `demos`, and
`supported-models` `... - Comfy` suffixes. `BaseLayout` reuses each page
title for `og:title`, `twitter:title`, and the JSON-LD `name`, so those
inherit the change from a single source per page.
- Updated the e2e title assertions to match. `launches` asserts via
`t()` so it follows the translation change automatically.

## Review Focus

- **Scope is titles only.** The crawl and this ticket are about meta
titles. Description / body copy em dashes are left untouched, they are
marketing copy, which we keep em-dashed per the comms-only styling
decision. A full JSON-LD audit across all 57 routes confirms every
`name`/title field is now em-dash-free.
- **zh-CN cloud-nodes title keeps its Chinese double dash `——`**
(`自定义节点包合集——开箱即用`). That is correct CJK punctuation, a dash break, not a
brand/Latin separator, so the Latin-script separator concern does not
apply. Its e2e assertion is left as `——` to match.
- No visual change: titles are `<head>` metadata, not rendered UI, so
there is nothing to screenshot on the page. Rendered `<title>` output
was verified on the dev server across inline, `t()`-based, zh-CN,
dynamic, and 404 routes, and `og:`/`twitter:` inheritance was confirmed.

Ref: FE-1233
2026-07-13 22:24:52 +00:00
pythongosssss
ffb4ae34c6 feat: App mode UI updates (#13170)
## Summary

App mode now uses the same sidenav as the graph view instead of its own
custom navigation, and switching between modes happens through a new
animated graph/app toggle shared by both views.

## Changes

- **What**:
- LinearView hosts the standard `SideToolbar`, filtered to the Assets
and Apps tabs via new `visibleTabIds`/`forceConnected` props. The
bottom-panel and shortcut toggles hide in app mode, and
GraphCanvas/SubgraphBreadcrumb hide their instances so only one toolbar
and toggle render at a time.
- The workflow actions dropdown trigger is now a two-segment graph/app
toggle: the inactive segment switches modes directly, the active one
opens the actions menu as before, with a FLIP animation on switch.
- Each view renders its own toggle, so switching unmounts one and mounts
the other. A `displayLinearMode` ref in canvasStore trails `linearMode`
by two frames so the incoming toggle animates from the old mode instead
of popping in already-switched.
- AppModeToolbar slims down to the toggle plus a "Build an app" button;
the per-icon Assets/Apps/Share buttons are covered by the sidenav.
- The Apps tab gains "Create" header and empty-state actions wired to
`Comfy.NewBlankWorkflow`.
- App-mode feedback moves into `SidebarHelpCenterIcon` as a Typeform
popover with a load-error fallback, replacing `TypeformPopoverButton`
and `LinearFeedback` and simplifying LinearPreview to a single
`OutputHistory`.
- Tests for the toggle, sidebar tabs, help-center fallback, preview, and
view; screenshots regenerated.

## Review Focus

- The two-frame `displayLinearMode` lag — timing-sensitive; rapid
toggling cancels the pending frame chain so the wrong mode never
flashes.
- `SideToolbar` now serves both views: confirm only one instance renders
and the new props behave.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/9ad1eb4f-9370-4975-b3f1-b271482f2bf7

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:52:34 +00:00
Matt Miller
59341a4b71 feat: data-driven provider input types with JSON file upload for BYOK secrets (#13571)
## ELI-5

Some cloud providers hand you an API key you type in a box; others (like
Google Vertex) hand you a whole JSON file. The provider list already
comes from the server. This teaches each provider to also say *how* its
credential is entered. When a provider says `json_file`, the "Add
Secret" form now shows an **Upload JSON file** button plus a paste box
instead of the single-line password field. Everything else works exactly
as before.

## Summary

Extends the server-driven secret provider schema with optional
`input_type` (`'text' | 'json_file'`) and `label`, and renders a
file-upload + paste textarea for `json_file` providers (e.g. a Vertex
service-account JSON) instead of the password field.

## Changes

- **What**:
- `SecretProvider` ingest schema gains optional `input_type` and `label`
(added to the generated `types.gen.ts` / `zod.gen.ts` — see Review
Focus).
- `listSecretProviders()` now returns the full provider objects (id +
optional `input_type`/`label`) instead of just ids; `availableProviders`
carries them through to the form.
- The credential field switches on the selected provider's `input_type`:
`json_file` → upload button (reads the file into the value) + paste
textarea with light "must be valid JSON" validation; anything else → the
existing password field.
- Provider display label prefers the server `label`, falling back to the
frontend registry label, then the raw id.
  - New i18n keys under `secrets.*`.
- **Breaking**: none. Providers that omit `input_type` render exactly as
today (single-line secret). Read/List responses are untouched — the
credential value is still never echoed back.

## Review Focus

- **Judgment call — one provider, credential kind inferred, no toggle.**
Per the design, a single provider is kept and the raw credential value
is sent as-is; the credential kind (AI Studio key vs Vertex SA JSON) is
inferred server-side. The design allowed "inferred from the upload *or*
an explicit toggle"; I chose inference to keep the UI minimal and avoid
introducing a new user-facing gating control. No provider ids are added
or renamed, so the proxy's 1:1 path→provider mapping is unaffected.
- **Generated-file edit (bridge).** The `input_type`/`label` fields were
hand-applied to the generated
`packages/ingest-types/src/{types,zod}.gen.ts`. The upstream
`openapi.yaml` (the generation source) is not in this repo, so this
models the wire contract additively until the backend spec lands the
same optional fields; a future regeneration reproduces them. The fields
are optional, so nothing breaks if the server omits them.
- **No secret ever echoed back.** Only the create request carries the
value; GET/List responses (metadata-only) are unchanged.

Tests: unit coverage added/updated for the provider metadata
passthrough, `selectedInputType`, server label precedence, file loading,
JSON validation, and the form rendering both branches. Targeted vitest
(79 secrets tests), `pnpm typecheck`, eslint, and oxfmt all green.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:21:27 +00:00
imick-io
97fffb5394 [feat] Add NEW badge to top-level header nav items (#13527)
## Summary
Adds an accent NEW badge next to top-level header nav items (Products,
Community) on both desktop and mobile.

## Changes
- **What**: New `badge?: 'new'` field on `NavItem`, rendered as an
accent `Badge` in `HeaderMainDesktop.vue` and `HeaderMainMobile.vue`;
adds a `xxs` badge size and drops hardcoded padding/size from the
`accent` variant so it composes with size.
- **Tests**: e2e coverage asserting the badge shows only on
Products/Community, not Company/Pricing.

## Review Focus
- The `accent` badge variant now inherits padding/font-size from `size`
instead of hardcoding them — verify existing `accent` usages still
render correctly.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-13 16:39:15 +00:00
Rizumu Ayaka
4972e4d42f fix(form-dropdown): stop cancelling native scroll on slow trackpad wheels (#12334)
## Summary

Follow-up to #12052. The local wheel guard added in FormDropdownMenu was
cancelling native scroll on slow macOS trackpad gestures, leaving image
rows blank in the asset picker until a fast scroll re-pumped samples
through.

## Changes

- **What**: `FormDropdownMenu.onWheel` now only `preventDefault`s on
pinch-zoom (`ctrl/meta + wheel`). The `|deltaX| > |deltaY|` branch of
`isCanvasGestureWheel` is no longer used here.
- **Why**: Slow vertical trackpad scrolls emit small-delta frames with
stray horizontal jitter (e.g. `deltaY=1.2`, `deltaX=1.5`) that satisfied
the horizontal-dominant predicate. Cancelling those frames starved
`VirtualGrid`'s throttled `useScroll`, so `Math.floor(scrollY /
itemHeight)` never advanced and tiles never mounted.
- Horizontal-swipe-to-navigate is still blocked at the page boundary by
`overscroll-behavior: none` on `html, body` (the actual fix from
#12052), so dropping the local horizontal guard does not reintroduce the
back/forward bug.
- The global `isCanvasGestureWheel` is unchanged —
`useCanvasInteractions` still uses it for the canvas wheel handler,
where the semantics are different (forward to canvas pan, not
preventDefault native scroll).

## Review Focus

- Confirm `overscroll-behavior: none` on `html, body` is sufficient on
its own for the horizontal-swipe-to-navigate case inside the teleported
dropdown. (It is — overscroll-behavior is decided per scroll container
at gesture start, independent of where in the page the gesture
originates.)
- Pinch-zoom in the dropdown still preventDefaults correctly.

Tested on macOS trackpad: slow vertical scroll now loads images
progressively; fast scroll unchanged; pinch-zoom in dropdown does not
zoom the page; horizontal swipe in dropdown does not navigate.

Related: this fixes the slow-scroll repro reported in #bug-dump. The
blank-on-reopen class of bug from FE-535 (#11885) is a different issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12334-fix-form-dropdown-stop-cancelling-native-scroll-on-slow-trackpad-wheels-3656d73d3650813a987ffa8d6f0eaa9c)
by [Unito](https://www.unito.io)
2026-07-13 16:08:29 +00:00
Terry Jia
8d8ae7dfc0 feat: share one WebGL context across all 3D views (#13547)
## Summary
Browsers cap live WebGL contexts per page, so each Load3D node owning
its own renderer broke once enough 3D nodes were added. All 3D views now
share a single offscreen WebGLRenderer: each view renders into the
bottom-left region of the shared drawing buffer and blits the frame into
its own plain 2D canvas, which has no context limit.

- Add sharedWebGLRenderer (refcounted singleton, grow-only buffer) and
RendererView (per-view canvas + per-view renderer state + blit)
- Per-view renderer state (tone mapping, color space, clear color) is
applied before each view renders; HDRIManager and SceneModelManager
write to it instead of the renderer
- OrbitControls/TransformControls bind to the node container explicitly
- ViewHelperManager renders the axes helper itself so it can position
and scale independently of renderer DOM layout
- RecordingManager records from the view canvas; capture renders at
exact size on the shared buffer as before
- On last view release the context is force-lost and webglcontextlost is
dispatched synchronously so context-loss handlers still run

## Screenshots (if applicable)
before
<img width="2155" height="1527" alt="image"
src="https://github.com/user-attachments/assets/35a7ab8a-28dd-41ba-8349-525bd2e97977"
/>

after
<img width="918" height="430" alt="屏幕截图 2026-07-09 135025"
src="https://github.com/user-attachments/assets/67328577-bd82-4494-921a-6d298442d081"
/>
<img width="1636" height="1509" alt="image"
src="https://github.com/user-attachments/assets/a3be9215-7cd0-427c-8264-1fd964d3ace1"
/>
2026-07-13 09:50:30 -04:00
Pablo
38c034e1d6 feat(load3d): prototype top-bar chrome for embedded 3D viewer (#13205)
## Summary

Prototype redesign of the embedded 3D viewer controls into a framed
top/bottom-bar chrome. Proof-of-concept for design exploration — not
intended to merge as-is.

## Changes

- **What**: Replaces the floating viewer controls (`Load3DControls`)
with a new `Load3DMenuBar` framed chrome: a black top bar with a
category dropdown (Scene / 3D Model / Camera / Lighting) and the active
category's actions (labels collapse to icons on narrow nodes), plus a
black bottom bar with Record (left) and fit + export (right). Export is
moved out of the menu into the bottom-right button. Adds a "Clay"
material mode that renders meshes with a flat grey material so geometry
is visible without textures.
- **Breaking**: None — `Load3D.vue` swaps the component and hides its
existing right-side button column behind `v-if="false"`; nothing is
deleted.

## Review Focus

- Design prototype: `Load3D.vue` swaps `Load3DControls` →
`Load3DMenuBar` and hides the existing fit/center/expand/record column.
The original components are left untouched.
- The only changes outside the prototype component are for the **Clay**
material mode, which touches shared engine code: `MaterialMode` type
(`interfaces.ts`), the runtime material switch (`SceneModelManager.ts`),
the capability lists (`MeshModelAdapter.ts`, `ModelAdapter.ts`,
`useLoad3d.ts`), and the i18n label.
- Known prototype gaps: **Record** is a visual toggle only (pulsing dot,
not wired to real recording); **fit** reuses the existing
`handleFitToViewer`; **export** reuses the existing format options.

## Screenshots (if applicable)

Prototype walkthrough captured during development (top-bar chrome,
category dropdown, labeled-vs-icon collapse, Clay material, pulsing
Record). Available on request.

---------

Co-authored-by: PabloWiedemann <PabloWiedemann@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-12 23:09:54 -04:00
297 changed files with 10516 additions and 4529 deletions

View File

@@ -22,29 +22,6 @@ reviews:
docstrings:
mode: 'off'
custom_checks:
- name: Reproduction and contribution evidence
mode: error
instructions: |
Use only PR metadata already available in the review context: the PR
description, changed-file list relative to the base, and diff content.
Do not run shell commands or follow links to infer missing evidence.
Fail when the PR description does not contain concrete reproduction or
validation steps that another reviewer can follow.
The PR must select exactly one evidence type:
1. Visual: require either distinct Before and After screenshot links, or
one screencast link that demonstrates the before and after behavior.
2. Non-visual: allow this only when the diff has no user-visible behavior
or presentation change. Require an `N/A:` rationale plus a concrete
test command and result or relevant log evidence.
Fail placeholders, generic statements such as `done` or `tested`, a
screenshot used as both Before and After, and Non-visual selections for
changes to rendered UI, styling, interaction, or user-visible states.
Pass otherwise. The repository metadata workflow validates field shape;
this check validates whether the supplied evidence matches the diff.
- name: End-to-end regression coverage for fixes
mode: error
instructions: |

View File

@@ -39,37 +39,6 @@ body:
validations:
required: true
- type: dropdown
id: evidence-type
attributes:
label: Evidence type
description: Choose the evidence that supports this report.
options:
- Visual
- Non-visual
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: >-
Visual reports require distinct before and after links or one screencast.
For non-visual reports, explain why visuals do not apply and include test
or log output.
placeholder: |
Visual:
Before: Drag in a screenshot.
After: Drag in a screenshot.
Or Screencast: Drag in one recording that shows both states.
Non-visual:
N/A: Explain why visual evidence does not apply.
Test/log evidence: Paste the command and result, logs, or a link.
validations:
required: true
- type: dropdown
id: severity
attributes:

View File

@@ -29,46 +29,6 @@ body:
- While collaborating with team members...
validations:
required: true
- type: textarea
id: example
attributes:
label: Concrete example or steps
description: Show the exact workflow or steps where the problem occurs.
placeholder: |
1. Open a workflow with...
2. Try to...
3. Observe...
validations:
required: true
- type: dropdown
id: evidence-type
attributes:
label: Evidence type
description: Choose the evidence that supports this request.
options:
- Visual
- Non-visual
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: >-
Visual requests require distinct before and after links or one screencast.
For non-visual requests, explain why visuals do not apply and include test
or log output.
placeholder: |
Visual:
Before: Drag in a screenshot.
After: Drag in a screenshot.
Or Screencast: Drag in one recording that shows both states.
Non-visual:
N/A: Explain why visual evidence does not apply.
Test/log evidence: Paste the command and result, logs, or a link.
validations:
required: true
- type: dropdown
id: frequency
attributes:

View File

@@ -15,35 +15,6 @@
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
## Reproduction or validation steps
## Screenshots (if applicable)
<!-- Required. List the exact steps a reviewer can follow. -->
1.
## Evidence type
<!-- Required. Check exactly one. -->
- [ ] Visual
- [ ] Non-visual
## Before
<!-- Visual: add a screenshot link. Leave blank when using a screencast. -->
## After
<!-- Visual: add a screenshot link. Leave blank when using a screencast. -->
## Screencast
<!-- Visual alternative: add one link that demonstrates before and after. -->
## Non-visual rationale
<!-- Non-visual only. Start with "N/A:" and explain why visuals do not apply. -->
## Test or log evidence
<!-- Non-visual only. Paste the command and result, logs, or a link. -->
<!-- Add screenshots or video recording to help explain your changes -->

View File

@@ -41,20 +41,6 @@ jobs:
title: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
body: |
Automated update of desktop API types to version ${{ steps.get-version.outputs.NEW_VERSION }}.
## Reproduction or validation steps
1. Review the package and lockfile changes for the expected version.
2. Confirm the desktop API type checks pass in CI.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated dependency update has no visual behavior.
## Test or log evidence
`pnpm install --workspace-root @comfyorg/comfyui-electron-types@latest` passed in workflow run `${{ github.run_id }}`.
branch: update-electron-types-${{ steps.get-version.outputs.NEW_VERSION }}
base: main
labels: |

View File

@@ -98,20 +98,6 @@ jobs:
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
## Reproduction or validation steps
1. Review the generated type diff against the referenced Manager commit.
2. Confirm the generated type lint and repository checks pass in CI.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated generated-type update has no visual behavior.
## Test or log evidence
`pnpm lint:fix:no-cache -- ./src/types/generatedManagerTypes.ts` passed in workflow run `${{ github.run_id }}`.
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
base: ${{ inputs.target_branch }}
labels: Manager

View File

@@ -1,117 +0,0 @@
# Description: Labels and reports issues blocked by missing evidence or a Comfy human owner.
name: 'CI: Issue Evidence'
on:
issues:
types: [opened, edited, reopened, assigned, unassigned]
permissions:
contents: read
issues: write
concurrency:
group: issue-evidence-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
issue-evidence:
runs-on: ubuntu-latest
steps:
- name: Checkout default-branch validator
uses: actions/checkout@v6
with:
ref: ${{ github.workflow_sha }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Validate issue evidence
id: evidence
continue-on-error: true
run: node scripts/cicd/validate-evidence.ts issue "$GITHUB_EVENT_PATH"
- name: Validate human maintainer assignment
id: maintainer
continue-on-error: true
env:
COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}
run: node scripts/cicd/validate-maintainer.ts "$GITHUB_EVENT_PATH"
- name: Update blocked labels
if: always()
uses: actions/github-script@v8
env:
EVIDENCE_VALID: ${{ steps.evidence.outputs.valid }}
MAINTAINER_VALID: ${{ steps.maintainer.outputs.valid }}
with:
script: |
const issue_number = context.issue.number
const { owner, repo } = context.repo
const policies = [
{
description: 'Blocked until required reproduction and evidence details are added',
name: 'blocked: needs-evidence',
valid: process.env.EVIDENCE_VALID === 'true'
},
{
description: 'Blocked until an active human comfy_frontend_devs member is assigned',
name: 'blocked: needs-maintainer',
valid: process.env.MAINTAINER_VALID === 'true'
}
]
for (const policy of policies) {
if (policy.valid) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: policy.name
})
} catch (error) {
if (error.status !== 404) throw error
}
continue
}
try {
await github.rest.issues.getLabel({
owner,
repo,
name: policy.name
})
} catch (error) {
if (error.status !== 404) throw error
await github.rest.issues.createLabel({
owner,
repo,
name: policy.name,
color: 'B60205',
description: policy.description
}).catch((createError) => {
const alreadyExists =
createError.status === 422 &&
createError.response?.data?.errors?.some(
(detail) => detail.code === 'already_exists'
)
if (!alreadyExists) throw createError
})
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [policy.name]
})
}
- name: Fail blocked contribution
if: >-
always() &&
(steps.evidence.outputs.valid != 'true' ||
steps.maintainer.outputs.valid != 'true')
run: exit 1

View File

@@ -1,67 +0,0 @@
# Description: Enforces PR evidence and a Comfy human owner with trusted base-branch code.
name: 'CI: PR Evidence'
on:
pull_request_target:
types:
[
opened,
edited,
synchronize,
reopened,
ready_for_review,
assigned,
unassigned
]
merge_group:
types: [checks_requested]
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-evidence-${{ github.event.pull_request.number || github.event.merge_group.head_sha }}
cancel-in-progress: true
jobs:
pr-evidence:
runs-on: ubuntu-latest
steps:
- name: Checkout trusted validator
uses: actions/checkout@v6
with:
ref: ${{ github.workflow_sha }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Validate human maintainer assignment
id: maintainer
if: github.event_name == 'pull_request_target'
continue-on-error: true
env:
COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}
run: node scripts/cicd/validate-maintainer.ts "$GITHUB_EVENT_PATH"
- name: Validate pull request evidence
id: evidence
if: github.event_name == 'pull_request_target'
continue-on-error: true
run: node scripts/cicd/validate-evidence.ts pr "$GITHUB_EVENT_PATH"
- name: Enforce contribution policy
if: >-
github.event_name == 'pull_request_target' &&
(steps.maintainer.outputs.valid != 'true' ||
steps.evidence.outputs.valid != 'true')
run: exit 1
- name: Validate current merge group metadata
if: github.event_name == 'merge_group'
env:
COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/cicd/validate-merge-group.ts "$GITHUB_EVENT_PATH"

View File

@@ -49,20 +49,6 @@ jobs:
Automated PR to update locales for node definitions
This PR was created automatically by the frontend update workflow.
## Reproduction or validation steps
1. Review the locale diff for the expected node-definition strings.
2. Confirm locale generation and repository checks pass in CI.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated locale data update has no visual behavior.
## Test or log evidence
`pnpm locale` passed in workflow run `${{ github.run_id }}`.
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
base: main
labels: dependencies

View File

@@ -348,34 +348,22 @@ jobs:
run: |
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author,body)
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
PR_SOURCE_BODY=$(echo "$PR_DATA" | jq -r '.body // ""')
else
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
PR_SOURCE_BODY=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH")
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r target branch <<< "${backport}"
PR_BODY=$(cat <<EOF
Backport of #${PR_NUMBER} to \`${target}\`.
Automatically created by the backport workflow.
## Source pull request evidence
${PR_SOURCE_BODY}
EOF
)
if PR_URL=$(gh pr create \
--base "${target}" \
--head "${branch}" \
--title "[backport ${target}] ${PR_TITLE}" \
--body "${PR_BODY}" \
--body "Backport of #${PR_NUMBER} to \`${target}\`"$'\n\n'"Automatically created by backport workflow." \
--label "backport" 2>&1); then
# Extract PR number from URL

View File

@@ -23,6 +23,10 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -49,12 +53,15 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -103,6 +110,7 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

View File

@@ -202,20 +202,6 @@ jobs:
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
## Reproduction or validation steps
1. Review the package and lockfile version changes.
2. Confirm release checks pass for the target branch in CI.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated version bump has no visual behavior.
## Test or log evidence
`pnpm version ${{ steps.prepared-inputs.outputs.version_type }} --no-git-tag-version` passed in workflow run `${{ github.run_id }}`.
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ steps.prepared-inputs.outputs.branch }}
labels: |

View File

@@ -63,20 +63,6 @@ jobs:
snapshot (with a warning annotation in CI).
Triggered by workflow run `${{ github.run_id }}`.
## Reproduction or validation steps
1. Review both regenerated snapshot diffs for expected remote data.
2. Confirm the Vercel website preview builds successfully.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated snapshot refresh changes data files only.
## Test or log evidence
Status: success for snapshot generation in workflow run `${{ github.run_id }}`.
branch: chore/refresh-website-snapshots-${{ github.run_id }}
base: main
labels: |

View File

@@ -86,20 +86,6 @@ jobs:
${{ steps.capitalised.outputs.capitalised }} version increment for @comfyorg/desktop-ui to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ github.event.inputs.branch }}`
## Reproduction or validation steps
1. Review the desktop UI package and lockfile version changes.
2. Confirm desktop UI release checks pass for the target branch in CI.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated package version bump has no visual behavior.
## Test or log evidence
`pnpm -C apps/desktop-ui version ${{ github.event.inputs.version_type }} --no-git-tag-version` passed in workflow run `${{ github.run_id }}`.
branch: desktop-ui-version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ github.event.inputs.branch }}
labels: |

View File

@@ -84,7 +84,6 @@ jobs:
- ## Review Notes section with any important context
3. Be specific about which files were updated and why
4. If no changes were needed, write a brief message stating documentation is up to date
5. Do not add evidence-contract headings; the workflow appends those sections
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--max-turns 256 --allowedTools 'Bash(git status),Bash(git diff),Bash(git log),Bash(pnpm:*),Bash(npm:*),Bash(node:*),Bash(tsc:*),Bash(echo:*),Read,Write,Edit,Glob,Grep'"
continue-on-error: false
@@ -128,26 +127,6 @@ jobs:
EOF
fi
- name: Add required PR evidence
if: steps.check_changes.outputs.has_changes == 'true'
run: |
cat >> /tmp/pr-body-${{ github.run_id }}.md <<'EOF'
## Reproduction or validation steps
1. Review each documentation diff against the current implementation.
2. Confirm referenced commands and links work as documented.
## Evidence type
- [ ] Visual
- [x] Non-visual
## Non-visual rationale
N/A: This automated review changes documentation only.
## Test or log evidence
Status: success. The documentation review completed before this PR was created.
EOF
- name: Create or Update Pull Request
if: steps.check_changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0

View File

@@ -258,11 +258,8 @@ The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is
1. Ensure your branch is up to date with main
2. Run all tests and ensure they pass
3. Create a pull request with a clear title, description, and exact reproduction or validation steps
4. Include contribution evidence:
- For visual behavior or presentation changes, add distinct Before and After screenshots or one screencast that demonstrates both states.
- For a truly non-visual change, select Non-visual, add an `N/A:` rationale, and include the test command and result or relevant logs.
5. Use conventional commit format for PR titles:
3. Create a pull request with a clear title and description
4. Use conventional commit format for PR titles:
- `feat:` for new features
- `fix:` for bug fixes
- `docs:` for documentation
@@ -273,51 +270,9 @@ The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is
### Review Process
1. All PRs require at least one review
2. Issues and pull requests under active execution require an active human member of the Comfy-Org `comfy_frontend_devs` team as an assignee. Automation and service accounts are executors, not accountable owners. Choose the person with the most context, using CODEOWNERS and recent changes to the related code path. The policy preserves existing assignees; COMOS performs contextual selection for artifacts it creates.
3. Address review feedback promptly
4. Keep PRs focused - one feature/fix per PR
5. Large features should be discussed in an issue first
The `pr-evidence` check fails until both the evidence contract and human owner
contract are satisfied. Issues receive `blocked: needs-evidence` and
`blocked: needs-maintainer` labels as applicable, and the policy workflow fails
until both conditions are fixed. Opaque GitHub attachment links in Screencast
fields are fetched without credentials and accepted only when GitHub serves
video content or an animated GIF. A static screenshot labeled as a screencast
does not satisfy the check.
Repository administrators must configure the
`COMFY_ORG_MEMBERS_READ_TOKEN` Actions secret before enabling `pr-evidence` in
the ProtectMain or Core release branch rulesets. Use a dedicated fine-grained
token with `Comfy-Org` organization `Members: read` permission, including team
membership reads, and no repository write permission. The trusted workflows use
it to verify the assignee's GitHub user type, active organization membership,
and active `comfy_frontend_devs` team membership. They fail closed when the
secret or any lookup is unavailable. Merge the workflow to the default branch
and verify a successful check before making it required.
#### Contribution policy rollout
Do not make `pr-evidence` required when the workflow first lands. Use this
sequence so existing open contributions are not blocked without warning:
1. Configure and verify `COMFY_ORG_MEMBERS_READ_TOKEN`.
2. Merge the policy workflows while `pr-evidence` is not a required check. New
and updated contributions still report failures and blocked labels, which is
the audit signal.
3. Observe a small canary across manual, fork, and automated contributions.
Confirm assignment edits rerun the check and valid edits clear issue labels.
4. Audit and backfill the active pull request and issue queues with evidence and
a contextual Comfy-Org human assignee.
5. Add `pr-evidence` to ProtectMain ruleset `991238` only after the active main
queue is ready.
6. Test a canary against both `core/**` and `cloud/**`. Add `pr-evidence` to Core
release branches ruleset `7297873` only after both targets are confirmed to
trigger the trusted workflow. Confirm the merge-group check refetches and
validates current PR bodies and assignees before enabling the ruleset.
The workflow is intentionally fail closed during the audit phase. It remains
non-blocking only because the rulesets do not require its check yet.
2. Address review feedback promptly
3. Keep PRs focused - one feature/fix per PR
4. Large features should be discussed in an issue first
## Questions?

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers Comfy')
await expect(page).toHaveTitle('Careers - Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
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).toHaveTitle('招聘 - Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud supported by default'
'Custom-node packs on Comfy Cloud - supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop Run AI on Your Hardware'
'Download Comfy Desktop - Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

View File

@@ -13,7 +13,7 @@ test.describe('Learning page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning Comfy')
await expect(page).toHaveTitle('Learning - Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
@@ -137,7 +137,7 @@ test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 Comfy')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)

View File

@@ -31,6 +31,26 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -117,6 +137,27 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful Comfy')
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed Comfy')
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 Comfy')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 Comfy')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -16,6 +16,7 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -42,7 +43,10 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
{{ navItem.label }}
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,6 +8,7 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -96,7 +97,8 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
{{ item.label }}
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -12,9 +11,7 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

View File

@@ -6,14 +6,15 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -30,15 +30,23 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| { label: string; href: string; columns?: never; featured?: never }
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -95,6 +103,7 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

View File

@@ -823,7 +823,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud supported by default',
en: 'Custom-node packs on Comfy Cloud - supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -1845,8 +1845,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -3483,8 +3483,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3896,8 +3896,8 @@ const translations = {
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA Comfy',
'zh-CN': 'Enterprise MSA Comfy'
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
@@ -4361,8 +4361,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4387,8 +4387,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 Page Not Found Comfy"
title="404 - Page Not Found - Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="About Us Comfy"
title="About Us - Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -42,7 +42,7 @@ const roles = itemListNode(
---
<BaseLayout
title="Careers Comfy"
title="Careers - Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies Comfy">
<BaseLayout title="Case Studies - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud AI in the Cloud"
title="Comfy Cloud - AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing Comfy Cloud"
title="Pricing - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },

View File

@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Contact Comfy"
title="Contact - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories Comfy">
<BaseLayout title="Customer Stories - Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} Comfy`}>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Download Comfy Desktop Run AI on Your Hardware"
title="Download Comfy Desktop - Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery Comfy">
<BaseLayout title="Gallery - Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
---
<BaseLayout
title="Comfy Professional Control of Visual AI"
title="Comfy - Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning Comfy">
<BaseLayout title="Learning - Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models Comfy"
title="Models - Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -103,7 +103,7 @@ const faqPage: JsonLdNode = {
---
<BaseLayout
title={`${pageTitle} Comfy`}
title={`${pageTitle} - Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}

View File

@@ -43,7 +43,7 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Failed Comfy"
title="Payment Failed - Comfy"
description="Your payment was not completed."
noindex
>

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Successful Comfy"
title="Payment Successful - Comfy"
description="Your payment was processed successfully."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
---
<BaseLayout
title="Privacy Policy Comfy"
title="Privacy Policy - Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy Comfy"
title="Desktop Privacy Policy - Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />

View File

@@ -6,7 +6,7 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service Comfy"
title="Terms of Service - Comfy"
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Videos Comfy">
<BaseLayout title="Videos - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="关于我们 Comfy"
title="关于我们 - Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}

View File

@@ -42,7 +42,7 @@ const roles = itemListNode(
---
<BaseLayout
title="招聘 Comfy"
title="招聘 - Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="案例研究 Comfy">
<BaseLayout title="案例研究 - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud 云端 AI"
title="Comfy Cloud - 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>

View File

@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="定价 Comfy Cloud"
title="定价 - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{

View File

@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="联系我们 Comfy"
title="联系我们 - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories'
const stories = (await loadStories('zh-CN')).map(toCardProps)
---
<BaseLayout title="客户故事 Comfy">
<BaseLayout title="客户故事 - Comfy">
<HeroSection locale="zh-CN" client:load />
<StorySection stories={stories} locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} Comfy`}>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 Comfy" description="ComfyUI 的互动演示和教程。">
<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">

View File

@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="下载 Comfy 桌面版 在您的硬件上运行 AI"
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[

View File

@@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
---
<BaseLayout title="作品集 Comfy">
<BaseLayout title="作品集 - Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />

View File

@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
---
<BaseLayout
title="Comfy 视觉 AI 的最强可控性"
title="Comfy - 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}

View File

@@ -11,7 +11,7 @@ import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 Comfy">
<BaseLayout title="学习 - Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
---
<BaseLayout
title="模型 Comfy"
title="模型 - Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 Comfy" description="您的支付未能完成。" noindex>
<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 Comfy" description="您的支付已成功完成。" noindex>
<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="隐私政策 Comfy"
title="隐私政策 - Comfy"
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 Comfy"
title="Desktop 隐私政策 - Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="视频 Comfy">
<BaseLayout title="视频 - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -61,7 +61,12 @@ const { drop, locale } = defineProps<{
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
<Badge
v-if="drop.badge"
size="xs"
variant="accent"
class="absolute top-6 left-8"
>
{{ drop.badge[locale] }}
</Badge>
</CardContent>

View File

@@ -28,6 +28,7 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -70,6 +71,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -104,6 +106,11 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
export class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator

View File

@@ -0,0 +1,40 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The graph/app view-mode toggle and its workflow actions dropdown.
* A separate instance mounts in each host - the subgraph breadcrumb (graph
* mode) and the app-mode center panel - and unmounts as the mode flips.
*/
export class WorkflowActionsDropdown {
/** The segmented graph/app toggle hosting the workflow actions trigger. */
public readonly viewModeToggle: Locator
/** The active segment; opens the workflow actions menu. */
public readonly trigger: Locator
/** The inactive segment that switches into app mode. */
public readonly enterAppModeSegment: Locator
/** The inactive segment that switches back to the node graph. */
public readonly enterGraphSegment: Locator
/** The workflow actions dropdown menu. */
public readonly menu: Locator
constructor(page: Page) {
this.viewModeToggle = page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
this.trigger = this.triggerIn(this.viewModeToggle)
this.enterAppModeSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter app mode'
})
this.enterGraphSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter node graph'
})
this.menu = page.getByRole('menu', { name: 'Workflow actions' })
}
/** The trigger as rendered inside a specific mode's host. */
triggerIn(host: Locator): Locator {
return host.getByRole('button', { name: 'Workflow actions' })
}
}

View File

@@ -4,6 +4,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { WorkflowActionsDropdown } from '@e2e/fixtures/components/WorkflowActionsDropdown'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -19,6 +20,7 @@ export class AppModeHelper {
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
readonly workflowActions: WorkflowActionsDropdown
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
@@ -77,6 +79,7 @@ export class AppModeHelper {
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.workflowActions = new WorkflowActionsDropdown(comfyPage.page)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
@@ -185,10 +188,7 @@ export class AppModeHelper {
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.workflowActions.trigger.click()
await this.page
.getByRole('menuitem', { name: /Build app|Edit app/ })
.click()

View File

@@ -239,6 +239,9 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
@@ -276,6 +279,7 @@ export const TestIds = {
overlay: 'loading-overlay'
},
load3d: {
categoryMenu: 'load3d-category-menu',
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {

View File

@@ -1,9 +1,14 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
const { centerPanel } = comfyPage.appMode
@@ -137,6 +142,117 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shows a single side toolbar per mode, filtered to assets + apps in app mode', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toHaveCount(1)
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode shows only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toHaveCount(1)
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { workflowActions, centerPanel } = comfyPage.appMode
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = workflowActions.triggerIn(
subgraphBreadcrumb.panel.root
)
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel).toBeVisible()
const appActions = workflowActions.triggerIn(centerPanel)
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await expect(workflowActions.viewModeToggle).toBeVisible()
await workflowActions.enterAppModeSegment.click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.viewModeToggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await workflowActions.enterAppModeSegment.focus()
await workflowActions.enterAppModeSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.trigger).toBeFocused()
})
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
// Exiting the builder lands in graph mode: the app-mode-only center panel
// stays hidden while the graph-mode toggle host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,39 +11,47 @@ export class Load3DHelper {
}
get menuButton(): Locator {
return this.node.getByRole('button', { name: /show menu/i })
return this.node.getByTestId(TestIds.load3d.categoryMenu)
}
private get menuPanel(): Locator {
return this.node.page().getByRole('dialog')
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: /start recording/i })
return this.node.getByRole('button', { name: 'Record', exact: true })
}
get stopRecordingButton(): Locator {
return this.node.getByRole('button', { name: /stop recording/i })
}
get exportRecordingButton(): Locator {
return this.node.getByRole('button', { name: /export recording/i })
}
get clearRecordingButton(): Locator {
return this.node.getByRole('button', { name: /clear recording/i })
}
get recordingDuration(): Locator {
get recordingMenuButton(): Locator {
return this.node.getByTestId(TestIds.load3d.recordingDuration)
}
get downloadRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
}
get startNewRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
}
get deleteRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
}
get gridToggleButton(): Locator {
return this.node.getByRole('button', { name: /show grid/i })
}
get uploadBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /upload background image/i })
return this.node.getByRole('button', { name: 'BG Image' })
}
get removeBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /remove background image/i })
return this.node.getByRole('button', { name: 'Remove BG' })
}
get panoramaModeButton(): Locator {
@@ -54,6 +62,10 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get exportButton(): Locator {
return this.node.getByRole('button', { name: 'Export', exact: true })
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
@@ -63,11 +75,15 @@ export class Load3DHelper {
}
getMenuCategory(name: string): Locator {
return this.node.getByText(name, { exact: true })
return this.menuPanel.getByRole('button', { name, exact: true })
}
get gizmoToggleButton(): Locator {
return this.node.getByRole('button', { name: 'Gizmo' })
// The category chip is also named "Gizmo" once that category is active;
// only the toggle carries aria-pressed.
return this.node
.getByRole('button', { name: 'Gizmo' })
.and(this.node.locator('[aria-pressed]'))
}
get gizmoTranslateButton(): Locator {
@@ -83,13 +99,17 @@ export class Load3DHelper {
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset Transform' })
return this.node.getByRole('button', { name: 'Reset', exact: true })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openRecordingMenu(): Promise<void> {
await this.recordingMenuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()

View File

@@ -47,10 +47,12 @@ test.describe('Load3D', () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
await expect(load3d.exportButton).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
@@ -253,7 +255,7 @@ test.describe('Load3D', () => {
}
)
test('Recording controls show stop/export/clear buttons after a recording', async ({
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
comfyPage,
load3d
}) => {
@@ -265,20 +267,25 @@ test.describe('Load3D', () => {
await expect(load3d.stopRecordingButton).toBeVisible()
})
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
// Record for 1s wall-clock so the duration display settles on 00:01.
await comfyPage.delay(1000)
await load3d.stopRecordingButton.click()
await expect(load3d.recordingButton).toBeVisible()
await expect(load3d.exportRecordingButton).toBeVisible()
await expect(load3d.clearRecordingButton).toBeVisible()
await expect(load3d.recordingDuration).toHaveText('00:01')
await expect(load3d.recordingMenuButton).toBeVisible()
await expect(load3d.recordingMenuButton).toHaveText('00:01')
})
await test.step('Clear recording removes export and clear controls', async () => {
await load3d.clearRecordingButton.click()
await expect(load3d.exportRecordingButton).toHaveCount(0)
await expect(load3d.clearRecordingButton).toHaveCount(0)
await test.step('Chip menu offers download, re-record and delete actions', async () => {
await load3d.openRecordingMenu()
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
})
await test.step('Deleting the recording restores the record button', async () => {
await load3d.deleteRecordingMenuItem.click()
await expect(load3d.recordingMenuButton).toHaveCount(0)
await expect(load3d.recordingButton).toBeVisible()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -46,7 +46,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
// Center the node so its header clears the view-mode toggle floating
// at the top-left of the canvas.
const nodeRef = await comfyPage.nodeOps.getNodeRefById(nodeId)
await nodeRef.centerOnNode()
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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