Compare commits

...

9 Commits

Author SHA1 Message Date
Marwan Ahmed
5fb48a8718 feat(website): interactive node-workflow hero with simulated runs 2026-07-08 14:52:19 +03:00
imick-io
010389903d feat(website): sitewide announcement banner (#13481)
## Summary

Adds a **sitewide announcement banner** to the website, rendered above
the navbar on every page. It has a two-layer visibility model:

- **Build-time gate** — a pure, unit-tested `evaluateBannerVisibility()`
decides whether the banner mounts at all (active flag + optional date
window + locale/section targeting), driven by a typed config
(`src/config/banner.ts`). No CMS; copy resolves through i18n.
- **Client-side dismissal** — persisted in `localStorage`, keyed by a
**content hash** so editing the copy re-shows the banner (per-locale, so
an en edit doesn't re-show it for zh-CN).

Current content points the CTA at **Comfy MCP** (`/mcp`).

## Highlights

- **Full-width branded bar** above a now-`sticky` navbar; reuses the
design system (`Button`, gradient tokens, new reusable `IconButton`).
- **Flash-free** on load: an inline pre-hydration script hides an
already-dismissed banner before paint (no pop-in, no layout shift);
`close()` sets the same signal after the leave animation to stay
flash-free across ClientRouter navigations.
- **Open/close transition**: grid-rows height collapse + fade,
respecting `prefers-reduced-motion`.
- **i18n**: copy in `en` + `zh-CN`.

## Where to edit later

- **Copy**: `apps/website/src/i18n/translations.ts` →
`launches.banner.text` / `launches.banner.cta`
- **Link / on-off / dates / targeting**:
`apps/website/src/config/banner.ts` (`bannerConfig`)

## Notes

- On a static site the `startsAt`/`endsAt` window is evaluated at
**build time** (documented in `banner.ts`).
- Changing the copy or link changes the content hash, so
previously-dismissed visitors will see the banner again — by design.

## Test plan

- `pnpm test:unit` — evaluator + version-hash unit tests pass.
- `pnpm typecheck` + lint clean.
- On the preview: banner shows on `/`, `/launches`, and a `zh-CN` page;
CTA -> `/mcp`; dismiss animates and stays dismissed on reload (no
flash); reduced-motion disables the animation.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:56:48 +00:00
Benjamin Lu
684b0b08b0 feat(telemetry): register client + deployment platform axes on PostHog (#13469)
## Problem

Filtering PostHog for the three product surfaces — **desktop local**,
**desktop cloud**, **web cloud** — currently requires a different hack
per pipe. For this repo's cloud build, the desktop-embedded frontend and
a plain browser are indistinguishable except by sniffing `Electron` in
`$raw_user_agent` (~124K desktop-cloud vs ~844K web-cloud execution
events/week get separated that way today).

## Change

Register the two standardized platform axes as PostHog super properties
at SDK init in `PostHogTelemetryProvider`:

- **`client`** — which surface emitted the event: `'desktop'` when the
desktop preload bridge (`window.__comfyDesktop2`) is present, else
`'web'`. The bridge is injected by Electron before any page script runs,
so detection is deterministic — unlike the existing utm-based
`source_app` attribution, which only covers sessions that *entered* via
a desktop link.
- **`deployment`** — which backend runs the work: pinned to `'cloud'`.

The register happens before the pre-init event queue flushes, so events
captured during the posthog-js dynamic-import window carry the axes too.

## Why pinning `deployment: 'cloud'` is safe (including
embedded-in-desktop)

The cloud bundle also runs **embedded in Comfy Desktop** — a cloud
install loads this same bundle in Electron, where `isCloud` and the host
bridge are both true. Two things happen there:

1. `main.ts` runs `initHostTelemetry()` *after* `initTelemetry()`, and
(when remote config `enable_telemetry` is on) it **replaces** the
registry with `HostTelemetrySink` — so tracked events
(`execution_start`, …) route through the desktop main process, bypassing
this provider. Those are tagged the same `client`/`deployment` values
main-side from the install's source category
([Comfy-Desktop#1229](https://github.com/Comfy-Org/Comfy-Desktop/pull/1229)).
2. posthog-js keeps capturing independently of the registry (pageviews,
web vitals, identify) — those are what these super properties cover in
the embedded case, and `deployment: 'cloud'` is correct for them because
the cloud bundle always talks to the cloud backend regardless of
embedding; the embedding itself is what `client: 'desktop'` captures.

The only way a cloud build runs against a non-cloud backend is a dev
setup, where `window.__CONFIG__.posthog_project_token` is absent
(injected by the cloud server) and the provider disables itself before
registering anything.

The locally-served frontend (desktop/localhost builds) never runs this
provider: `__DISTRIBUTION__` is a compile-time define, so the
`initTelemetry()` call folds away, with a runtime `IS_CLOUD_BUILD` guard
as backstop.

With both PRs, the three platforms become clean property filters:

| Surface | Filter |
|---|---|
| Desktop local | `client=desktop, deployment=local` |
| Desktop cloud | `client=desktop, deployment=cloud` |
| Web cloud | `client=web, deployment=cloud` |

## Testing

- `vitest run` on `PostHogTelemetryProvider.test.ts` — 45 passing,
including new coverage: web default, bridge-present → `client=desktop`,
and register-before-queue-flush ordering. The two desktop-entry tests
that asserted `register` is never called were narrowed to assert no
`source_app` register call.
- `pnpm typecheck` + eslint/oxlint on touched files — clean.

Ref
[MAR-51](https://linear.app/comfyorg/issue/MAR-51/foundation-desktop-sdk-dual-send-to-posthog-alongside-mixpanel)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-08 01:11:01 +00:00
Benjamin Lu
95b121bed9 fix(website): unbreak website-e2e on main (card count, CTA overflow, stale goldens) (#13496)
## Summary

Fix the website-e2e job, red on main since 2026-07-07 15:19 UTC, by
updating the cloud model-card count test and regenerating stale visual
screenshot goldens.

## Context

website-e2e runs Playwright tests against the marketing site
(`apps/website`). It broke on main in two independent ways, so every PR
since — however unrelated — has shown a red website-e2e check:

1. **Model-card count.** #13431 added a sixth model card (GPT Image 2)
to the /cloud "AI models" section but didn't update the test that pins
the card count at 5. CI: `locator resolved to 6 elements`.
2. **Stale screenshot goldens.** #13431 also swapped the ProductCard CTA
to the shared `Button` (`whitespace-nowrap`, so e.g. "SEE ENTERPRISE
FEATURES" renders on one line instead of wrapping) and committed
matching `home-product-cards-*` goldens. Minutes later #13445 — a branch
cut from main *before* #13431 — ran the screenshot-regen workflow, which
checks out the raw PR branch, not the branch merged with main. Its
regenerated lg/xl goldens therefore depict the **old** pre-#13431 card
(wrapped label; pixel-identical layout to the pre-#13431 golden), and
overwrote #13431's correct ones at merge. #13445's own website-e2e was
red at merge time for exactly this reason. The sm/md goldens (last
captured by #13431, without #13445's `ppformula-text-center`
0.19em→0.1em nudge) went stale by ~900 px the moment #13445 landed.

## Changes

- **What**: `cloud.spec.ts` — model-card count assertion and test title
5 → 6 (verified against the 6 entries in `AIModelsSection.vue`; passes
locally).
- **What**: `ProductCard.vue` — `h-auto whitespace-normal` on the CTA
`Button`. The shared Button's `whitespace-nowrap` made long labels ("SEE
ENTERPRISE FEATURES") overflow past the card edge at lg/xl (live on prod
since #13431); labels now wrap inside the card as they did before
#13431.
- **What**: regenerated `home-product-cards-*` goldens via the `Update
Website Screenshots` workflow, run on this branch, so they capture the
fixed rendering rather than enshrining the overflow.

## Review Focus

- At lg, "SEE DESKTOP/CLOUD FEATURES" now also wrap to two lines: #13431
raised the CTA font from `text-xs` to `md:text-sm`, so those labels no
longer fit one line in the 200px content box either (pre-fix they
silently consumed the card padding). If design prefers one-liners,
shrinking the CTA font is a follow-up.
- Process gaps this incident exposed (follow-ups, not in this PR): the
regen workflow captures against the raw branch instead of the
main-merged result, and website-e2e was red on #13445 at merge without
blocking it.

---

*Six cards where five once stood,*
*a button's text sat where it should —*
*but pixels pinned in amber lied,*
*so Linux looked, and rectified.*

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 17:59:21 -07:00
Wei Hai
baeb6df662 Gate signup submit on Turnstile being enabled, not enforced (#13463)
## Summary
- Shadow-mode Turnstile never blocked the signup Submit button on the
async Cloudflare challenge resolving, so most real submits raced ahead
of the widget and reached the backend with an empty token. This defeated
the point of shadow mode, which needs real tokens to measure the
false-positive rate before flipping to enforce.
- Submit is now blocked while the widget is enabled (shadow or enforce)
and has no token yet, in both modes.
- To keep a broken or slow Cloudflare load (network issue, ad-blocker,
CDN outage) from permanently blocking a legitimate signup,
`TurnstileWidget` now reports itself "unavailable" on a script-load
failure, a challenge error, or a 9s load timeout, and the form treats
that the same as shadow previously did: proceed without a token.

## Test plan
- [x] `vitest run` on `TurnstileWidget.test.ts` + `SignUpForm.test.ts`
(unit tests updated/added, all passing)
- [x] `pnpm typecheck` / eslint / oxlint / stylelint / oxfmt via
pre-commit hooks
- [ ] Manual click-through on staging to confirm no perceptible UX
regression during normal-latency challenge solves
- [ ] Confirm the 9s fallback timeout against real p95 Turnstile
challenge-solve latency

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-08 00:39:58 +00:00
CodeJuggernaut
e58b231664 fix: suppress the stray focus ring on the sidebar splitter panel (#13482)
Clicking empty space in a workspace panel focuses the whole PrimeVue
SplitterPanel (tabindex=-1 makes it click-focusable), and any following
non-chord keypress (e.g. Shift) trips the browser focus-visible
heuristic, painting the default blue ring around the entire panel.

The wrappers are not Tab-reachable (Tab lands on the controls inside,
never the panel box) and nothing focuses them programmatically, so the
ring conveys nothing. `focus-visible:outline-hidden` suppresses it while
keeping a forced-colors (High Contrast) indicator. Confirmed as noise
with design (Alex Tov).

Covers every click-focusable panel in
`LiteGraphCanvasSplitterOverlay.vue`: the sidebar panel (both
locations), the properties-side panel (both branches), and the bottom
panel. The center and graph-canvas panels are left untouched - they
inherit `pointer-events-none`, so a click can never focus them.

## Repro / QA

Ring trigger: click an empty, non-interactive spot inside the panel,
then press solo Shift. On main the browser paints a blue ring around the
whole panel; on this PR nothing appears. (Ctrl+Shift only triggers when
Shift lands first, hence the original "sometimes".)

| Panel | How to open | Fixed |
| --- | --- | --- |
| Sidebar (left, default) | Any rail icon, e.g. Assets | yes |
| Sidebar (right) | Settings > Sidebar Location > right | yes |
| Properties-side panel | Toggle properties panel / builder mode | yes |
| Bottom panel | Toggle Logs/Terminal | yes |
| Canvas / center | n/a | untouched - pointer-events-none, cannot be
click-focused |

- [ ] Each fixed panel: click empty spot, press Shift, no ring
- [ ] Same steps on main/prod show the ring (before-state)
- [ ] Tab still reaches controls inside each panel and their own focus
rings still show
- [ ] Media Assets shortcuts unchanged (Ctrl/Cmd+A, marquee modifiers) -
PR is CSS-only

- Surfaced during design review of #13323
2026-07-08 00:33:41 +00:00
Comfy Org PR Bot
545b48ee5b [chore] Update Ingest API types from cloud@421de6d (#12777)
## Automated Ingest API Type Update

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

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

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

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-07-08 00:29:59 +00:00
Benjamin Lu
22ea53fb56 fix(ci): remove out-of-rootDir config files from package tsconfig includes (#13485)
## Summary

Type-aware oxlint rejects `packages/ingest-types/tsconfig.json` and
`packages/object-info-parser/tsconfig.json` as invalid (TS6059): their
`include` lists a root-level config file (`openapi-ts.config.ts` /
`vitest.config.ts`) that sits outside `rootDir: "src"`. This fails the
lint-and-format job with "Invalid tsconfig" on any PR that touches those
packages' src files — currently blocking every auto-generated
ingest-types sync (e.g. #12777).

## Changes

- **What**: Drop the out-of-`rootDir` config-file entries from the two
package tsconfig `include` arrays, matching the other workspace
packages. Repro: `pnpm exec oxlint --type-aware
packages/ingest-types/src/index.ts` fails before, passes after; the
config files themselves still lint clean.

## Review Focus

Neither package emits a build, so `rootDir`/`outDir` are
editor/lint-only; excluding the config files from the project has no
runtime effect (vitest/openapi-ts load their configs directly).
2026-07-07 17:03:12 -07:00
cloud-code-bot[bot]
9fe5dd51b8 ci: bump cursor-review to github-workflows@df507e6 (#13493)
Automatic SHA bump — `cursor-review.yml` was updated in
`Comfy-Org/github-workflows` at
[`df507e6`](df507e6bae).
_Opened by the `bump-cursor-review-callers` workflow._

Co-authored-by: cloud-code-bot[bot] <234529496+cloud-code-bot[bot]@users.noreply.github.com>
2026-07-07 23:06:59 +00:00
95 changed files with 3647 additions and 219 deletions

View File

@@ -29,7 +29,7 @@ jobs:
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
@@ -48,7 +48,7 @@ jobs:
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
test('AIModelsSection heading and 6 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
await expect(modelCards).toHaveCount(6)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<Button as="span" variant="default" size="sm" class="mt-4">
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
{{ cta }}
</Button>
</div>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en', compact = false } = defineProps<{
locale?: Locale
compact?: boolean
}>()
const lines = computed(() => t('hero.title', locale).split('\n'))
const size = computed(() => (compact ? 'text-3xl sm:text-4xl' : 'text-5xl'))
const lineGap = computed(() => (compact ? '-mt-2' : 'mt-2'))
const pill =
'inline-block rounded-2xl px-5 py-2 font-formula-narrow leading-none font-semibold uppercase'
// PP Formula Narrow sits high in its em box; nudge the glyphs down so they read
// optically centered inside the highlighter block.
const inner = 'relative top-[0.06em] inline-block'
</script>
<template>
<div class="flex flex-col items-center text-center">
<h1 class="flex flex-col items-center">
<span
:class="
cn(pill, size, 'bg-primary-comfy-yellow text-primary-comfy-ink')
"
>
<span :class="inner">{{ lines[0] }}</span>
</span>
<span
:class="
cn(
pill,
size,
'bg-primary-comfy-yellow text-primary-comfy-ink',
lineGap
)
"
>
<span :class="inner">{{ lines[1] }}</span>
</span>
</h1>
<p
:class="
cn(
'max-w-md text-primary-comfy-canvas',
compact ? 'mt-5 text-sm/relaxed' : 'mt-8 text-base'
)
"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="externalLinks.cloud"
target="_blank"
variant="outline"
size="nav"
:class="cn('uppercase', compact ? 'mt-5' : 'mt-7')"
>
{{ t('hero.cta.cloud', locale) }}
</BrandButton>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ChevronDown, Minus, Plus } from '@lucide/vue'
import type { NodeWidget } from './heroWorkflowGraph'
const { widgets } = defineProps<{ widgets: NodeWidget[] }>()
</script>
<template>
<div class="flex flex-col gap-1">
<div
v-for="widget in widgets"
:key="widget.name"
class="bg-hero-node-inset flex h-7 items-center justify-between gap-2 rounded-lg px-2.5 text-xs"
>
<template v-if="widget.kind === 'number'">
<span class="flex min-w-0 items-center gap-2">
<Minus class="size-3 shrink-0 text-white/30" />
<span class="truncate text-white/40">{{ widget.name }}</span>
</span>
<span class="flex shrink-0 items-center gap-2 text-white/80">
<span class="tabular-nums">{{ widget.value }}</span>
<Plus class="size-3 text-white/30" />
</span>
</template>
<template v-else>
<span class="truncate text-white/40">{{ widget.name }}</span>
<span class="flex min-w-0 items-center gap-1 text-white/80">
<span class="truncate">{{ widget.value }}</span>
<ChevronDown
v-if="widget.kind === 'combo'"
class="size-3 shrink-0 text-white/35"
/>
</span>
</template>
</div>
</div>
</template>

View File

@@ -1,55 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import HeroWorkflow from './HeroWorkflow.vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
/>
</div>
<div class="flex-1 px-6 py-12 lg:px-16">
<h1
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
>
{{ t('hero.title', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
>
{{ t('hero.runFirstWorkflow', locale) }}
</BrandButton>
</div>
<section class="hero-dot-grid relative overflow-hidden bg-primary-comfy-ink">
<HeroWorkflow :locale />
</section>
</template>

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import HeroHeadline from './HeroHeadline.vue'
import HeroNodeWidgets from './HeroNodeWidgets.vue'
import HeroWorkflowNode from './HeroWorkflowNode.vue'
import HeroWorkflowOutput from './HeroWorkflowOutput.vue'
import {
NODE_TITLE_KEYS,
NODE_W,
STAGE_H,
STAGE_W,
clampNodePosition,
computeWires,
homePositions,
nodeWidgets
} from './heroWorkflowGraph'
import type {
NodeWidget,
Point,
Rect,
WorkflowNodeId
} from './heroWorkflowGraph'
import { useHeroWorkflowRun } from './useHeroWorkflowRun'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const run = useHeroWorkflowRun()
const { activeNode, nodeProgress, phase, seed, totalProgress } = run
const NODE_IDS: WorkflowNodeId[] = [
'model',
'clip',
'vae',
'lora',
'seed',
'output'
]
const percent = computed(() => Math.round(totalProgress.value * 100))
function widgetsFor(id: WorkflowNodeId): NodeWidget[] {
if (id === 'seed') {
return [
{ name: 'seed', value: String(seed.value), kind: 'number' },
{ name: 'control_after_generate', value: 'randomize', kind: 'combo' }
]
}
return nodeWidgets[id] ?? []
}
// The desktop graph is authored in a fixed design coordinate space and scaled
// as a single unit to fit the viewport width, so the whole composition stays on
// screen at every size. Node positions are live state so they can be dragged;
// widths are fixed per node and heights are measured once for wiring.
const MAX_SCALE = 1.3
const positions = ref<Record<WorkflowNodeId, Point>>(
structuredClone(homePositions)
)
const frameRef = ref<HTMLElement>()
const stageRef = ref<HTMLElement>()
const scale = ref(1)
const heights = ref<Record<string, number>>({})
// Heights are read from layout offsets (not getBoundingClientRect) so they stay
// in unscaled design coordinates regardless of the stage's scale transform.
function measureHeights() {
const stage = stageRef.value
if (!stage) return
const next: Record<string, number> = {}
stage.querySelectorAll<HTMLElement>('[data-node]').forEach((el) => {
next[el.dataset.node ?? ''] = el.offsetHeight
})
heights.value = next
}
function updateScale() {
const width = frameRef.value?.clientWidth ?? STAGE_W
scale.value = Math.min(width / STAGE_W, MAX_SCALE)
}
function refresh() {
updateScale()
measureHeights()
}
useResizeObserver(frameRef, refresh)
const stageStyle = computed(() => ({
width: `${STAGE_W}px`,
height: `${STAGE_H}px`,
transform: `translateX(-50%) scale(${scale.value})`
}))
function nodeStyle(id: WorkflowNodeId) {
const { x, y } = positions.value[id]
return {
transform: `translate3d(${x}px, ${y}px, 0)`,
width: `${NODE_W[id]}px`
}
}
// Wires recompute from live positions + measured heights, so they track the
// nodes synchronously while dragging with no measure round-trip.
const anchors = computed<Record<WorkflowNodeId, Rect>>(() => {
const ids = Object.keys(positions.value) as WorkflowNodeId[]
return Object.fromEntries(
ids.map((id) => [
id,
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
])
) as Record<WorkflowNodeId, Rect>
})
const dragging = ref<WorkflowNodeId | null>(null)
let drag = {
id: '' as WorkflowNodeId,
pointerId: -1,
px: 0,
py: 0,
ox: 0,
oy: 0
}
function onPointerDown(id: WorkflowNodeId, e: PointerEvent) {
if (e.button !== 0) return
drag = {
id,
pointerId: e.pointerId,
px: e.clientX,
py: e.clientY,
ox: positions.value[id].x,
oy: positions.value[id].y
}
dragging.value = id
}
// A small threshold keeps clicks on buttons from registering as drags.
function onPointerMove(e: PointerEvent) {
if (dragging.value == null || e.pointerId !== drag.pointerId) return
const dx = e.clientX - drag.px
const dy = e.clientY - drag.py
if (Math.hypot(dx, dy) < 4) return
positions.value[drag.id] = clampNodePosition(
drag.id,
{ x: drag.ox + dx / scale.value, y: drag.oy + dy / scale.value },
heights.value[drag.id] ?? 0
)
}
function onPointerUp() {
dragging.value = null
}
// Listeners live on window so a drag continues even when the pointer outruns
// the node; registered in onMounted to keep window off the SSR path.
onMounted(() => {
void nextTick(refresh)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
})
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
})
const wires = computed(() => computeWires(anchors.value))
</script>
<template>
<div class="relative w-full">
<!-- Execution progress pinned to the top of the hero, like the real app. -->
<div
v-if="phase === 'running'"
class="absolute inset-x-0 top-0 z-40"
data-testid="hero-total-progress"
>
<div class="h-1 bg-white/10">
<div
class="bg-hero-exec h-full transition-[width] duration-100 ease-linear"
:style="{ width: `${percent}%` }"
/>
</div>
<span class="absolute top-2.5 right-4 text-xs text-white/60 tabular-nums">
{{ t('hero.totalProgress', locale) }}:
<span class="font-semibold text-white">{{ percent }}%</span>
</span>
</div>
<!-- Desktop / large screens: a fixed design stage scaled to fit the width -->
<div
ref="frameRef"
class="relative hidden aspect-1600/780 max-h-[1000px] w-full lg:block"
>
<div
ref="stageRef"
data-testid="hero-stage"
class="absolute top-0 left-1/2 origin-top"
:style="stageStyle"
>
<svg
class="pointer-events-none absolute inset-0 size-full overflow-visible"
:viewBox="`0 0 ${STAGE_W} ${STAGE_H}`"
fill="none"
aria-hidden="true"
>
<path
v-for="(wire, i) in wires"
:key="i"
:d="wire.d"
:stroke="wire.color"
stroke-opacity="0.5"
stroke-width="1.5"
stroke-linecap="round"
/>
<template v-for="(wire, i) in wires" :key="`d${i}`">
<circle
:cx="wire.from.x"
:cy="wire.from.y"
r="3.5"
:fill="wire.color"
/>
<circle
:cx="wire.to.x"
:cy="wire.to.y"
r="3.5"
:fill="wire.color"
/>
</template>
<!-- Energy pulses that flow along every wire while the workflow runs;
idle-hidden via opacity, animated through CSS. -->
<g :class="cn(phase === 'running' && 'hero-wire-active')">
<path
v-for="(wire, i) in wires"
:key="`p${i}`"
:d="wire.d"
class="hero-wire-pulse"
:stroke="wire.color"
stroke-width="2.5"
stroke-linecap="round"
pathLength="1"
stroke-dasharray="0.18 0.82"
/>
</g>
</svg>
<!-- The headline stays beneath the nodes so a dragged node passes
cleanly over it instead of flipping layers mid-drag. -->
<div class="absolute top-[90px] left-[720px] -translate-x-1/2">
<HeroHeadline :locale />
</div>
<div
v-for="id in NODE_IDS"
:key="id"
:data-node="id"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === id && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle(id)"
@pointerdown="onPointerDown(id, $event)"
>
<HeroWorkflowNode
:title="t(NODE_TITLE_KEYS[id], locale)"
:state="run.nodeState(id)"
:progress="activeNode === id ? nodeProgress : 0"
>
<HeroWorkflowOutput v-if="id === 'output'" :run :locale />
<HeroNodeWidgets v-else :widgets="widgetsFor(id)" />
</HeroWorkflowNode>
</div>
</div>
</div>
<!-- Mobile / tablet: the loaders condense into a compact grid feeding the
Save Image node, so the whole workflow still fits one screen. -->
<div class="flex flex-col items-center px-5 pt-6 pb-10 lg:hidden">
<HeroHeadline :locale compact />
<div class="mt-6 w-full max-w-sm sm:max-w-md">
<div class="grid grid-cols-2 items-start gap-2">
<HeroWorkflowNode
v-for="id in ['model', 'clip', 'vae', 'lora'] as const"
:key="id"
:title="t(NODE_TITLE_KEYS[id], locale)"
:state="run.nodeState(id)"
:progress="activeNode === id ? nodeProgress : 0"
>
<HeroNodeWidgets :widgets="widgetsFor(id)" />
</HeroWorkflowNode>
</div>
<HeroWorkflowNode
class="mt-2"
:title="t(NODE_TITLE_KEYS.seed, locale)"
:state="run.nodeState('seed')"
:progress="activeNode === 'seed' ? nodeProgress : 0"
>
<HeroNodeWidgets :widgets="widgetsFor('seed')" />
</HeroWorkflowNode>
<div class="relative h-6 w-full" aria-hidden="true">
<svg
class="absolute inset-0 size-full"
viewBox="0 0 100 36"
preserveAspectRatio="none"
fill="none"
>
<path
d="M50 3 C 50 18 50 18 50 33"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span
class="absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="bg-hero-exec absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
</div>
<HeroWorkflowNode
:title="t(NODE_TITLE_KEYS.output, locale)"
:state="run.nodeState('output')"
:progress="activeNode === 'output' ? nodeProgress : 0"
>
<HeroWorkflowOutput :run :locale />
</HeroWorkflowNode>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronDown } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import type { NodeRunState } from './useHeroWorkflowRun'
const {
title,
state = 'idle',
progress = 0,
class: customClass = ''
} = defineProps<{
title: string
state?: NodeRunState
progress?: number
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'bg-hero-node overflow-hidden rounded-xl border shadow-xl shadow-black/30 transition-colors duration-300',
state === 'running' ? 'border-hero-exec' : 'border-white/10',
customClass
)
"
>
<div class="flex items-center gap-1.5 px-3 py-2">
<ChevronDown class="size-3.5 shrink-0 text-white/35" />
<span class="truncate text-[13px] font-medium text-white/85">
{{ title }}
</span>
</div>
<div class="h-0.5 bg-white/5">
<div
:class="
cn(
'bg-hero-exec h-full',
state === 'running' && 'transition-[width] duration-100 ease-linear'
)
"
:style="{ width: `${state === 'running' ? progress * 100 : 0}%` }"
/>
</div>
<div class="p-2">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ArrowUpRight, ImagePlus, Loader2, Play, RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import HeroNodeWidgets from './HeroNodeWidgets.vue'
import { NODE_TITLE_KEYS } from './heroWorkflowGraph'
import type { HeroWorkflowRun } from './useHeroWorkflowRun'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { run, locale = 'en' } = defineProps<{
run: HeroWorkflowRun
locale?: Locale
}>()
const filenameWidget = [
{ name: 'filename_prefix', value: 'Krea2_turbo', kind: 'text' as const }
]
const percent = computed(() => Math.round(run.totalProgress.value * 100))
const statusLabel = computed(() =>
run.activeNode.value
? t(NODE_TITLE_KEYS[run.activeNode.value], locale)
: t('hero.node.output', locale)
)
</script>
<template>
<HeroNodeWidgets :widgets="filenameWidget" />
<div
class="bg-hero-node-inset relative mt-1 aspect-square overflow-hidden rounded-lg"
>
<Transition name="hero-render">
<img
v-if="run.outputSrc.value"
:key="run.outputSrc.value"
:src="run.outputSrc.value"
:alt="t('hero.output.alt', locale)"
draggable="false"
class="absolute inset-0 size-full object-cover select-none"
/>
</Transition>
<div
v-if="run.phase.value === 'idle'"
class="absolute inset-0 flex flex-col items-center justify-center gap-4 p-6 text-center"
>
<span
class="flex size-11 items-center justify-center rounded-full bg-white/5"
>
<ImagePlus class="size-5 text-white/40" />
</span>
<p class="max-w-52 text-sm text-white/50">
{{ t('hero.output.hint', locale) }}
</p>
<button
type="button"
class="bg-hero-exec flex cursor-pointer items-center gap-2 rounded-lg px-7 py-2.5 text-sm font-semibold text-white transition-[filter] hover:brightness-110"
@click="run.run()"
>
<Play class="size-4 fill-current" />
{{ t('hero.run', locale) }}
</button>
</div>
<div
v-else-if="run.phase.value === 'running'"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/60"
>
<Loader2 class="text-hero-exec size-6 animate-spin" />
<p class="flex items-baseline gap-2 text-sm text-white/75">
<span>{{ statusLabel }}</span>
<span class="font-semibold text-white tabular-nums">
{{ percent }}%
</span>
</p>
</div>
</div>
<template v-if="run.phase.value === 'done'">
<div class="mt-2 flex items-center justify-between gap-2">
<span class="truncate font-mono text-[11px] text-white/40 tabular-nums">
{{ t('hero.output.seed', locale) }} {{ run.seed.value }}
</span>
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/10 px-3 py-1.5 text-xs font-medium text-white/85 transition-colors hover:bg-white/15"
@click="run.run()"
>
<RefreshCw class="size-3" />
{{ t('hero.runAgain', locale) }}
</button>
</div>
<a
:href="externalLinks.cloud"
target="_blank"
class="bg-primary-comfy-yellow mt-2 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-bold tracking-wide text-primary-comfy-ink uppercase transition-opacity hover:opacity-90"
>
{{ t('hero.output.openCloud', locale) }}
<ArrowUpRight class="size-3.5" />
</a>
</template>
</template>

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { Rect, WorkflowNodeId } from './heroWorkflowGraph'
import {
NODE_W,
STAGE_H,
STAGE_W,
clampNodePosition,
computeWires,
connections,
homePositions,
spline
} from './heroWorkflowGraph'
// Cubic command shape: "M sx sy C c1x c1y c2x c2y ex ey"
function controlPoints(d: string) {
const [sx, sy, c1x, c1y, c2x, c2y, ex, ey] = d
.replace(/[MC]/g, ' ')
.trim()
.split(/\s+/)
.map(Number)
return { sx, sy, c1x, c1y, c2x, c2y, ex, ey }
}
describe('spline', () => {
it('departs and arrives horizontally for side ports even when the vertical gap dominates', () => {
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
spline({ x: 0, y: 200 }, { x: 120, y: 0 }, 'h')
)
expect(c1y).toBe(sy)
expect(c2y).toBe(ey)
expect(c1x).toBeGreaterThan(sx)
expect(c2x).toBeLessThan(ex)
})
})
describe('computeWires', () => {
const anchors = Object.fromEntries(
(Object.keys(homePositions) as WorkflowNodeId[]).map((id) => [
id,
{ ...homePositions[id], w: NODE_W[id], h: 120 } satisfies Rect
])
) as Record<WorkflowNodeId, Rect>
it('produces one wire per connection with endpoints on the node edges', () => {
const wires = computeWires(anchors)
expect(wires).toHaveLength(connections.length)
for (const [i, wire] of wires.entries()) {
const from = anchors[connections[i].from]
const to = anchors[connections[i].to]
expect(wire.from.x).toBe(from.x + from.w)
expect(wire.to.x).toBe(to.x)
}
})
it('skips wires whose endpoints are not yet measured', () => {
const { model, lora } = anchors
expect(computeWires({ model, lora })).toHaveLength(1)
})
})
describe('clampNodePosition', () => {
it('keeps nodes fully inside the stage', () => {
const clamped = clampNodePosition('output', { x: 5000, y: -50 }, 560)
expect(clamped).toEqual({ x: STAGE_W - NODE_W.output, y: 0 })
expect(clampNodePosition('seed', { x: 100, y: 9999 }, 120).y).toBe(
STAGE_H - 120
)
})
})

View File

@@ -0,0 +1,190 @@
import { clamp } from 'es-toolkit'
import type { TranslationKey } from '../../i18n/translations'
export type WorkflowNodeId =
| 'model'
| 'clip'
| 'vae'
| 'lora'
| 'seed'
| 'output'
export interface Point {
x: number
y: number
}
export interface Rect extends Point {
w: number
h: number
}
export interface Wire {
d: string
from: Point
to: Point
color: string
}
export interface NodeWidget {
name: string
value: string
kind: 'combo' | 'number' | 'text'
}
export const STAGE_W = 1600
export const STAGE_H = 780
export const NODE_W: Record<WorkflowNodeId, number> = {
model: 300,
clip: 300,
vae: 300,
lora: 320,
seed: 280,
output: 460
}
// Loaders stack on the left, the LoRA + seed chain runs under the centred
// headline, and the Save Image node sits fully inside the right edge so
// nothing bleeds offscreen.
export const homePositions: Record<WorkflowNodeId, Point> = {
model: { x: 24, y: 70 },
clip: { x: 24, y: 280 },
vae: { x: 24, y: 490 },
lora: { x: 420, y: 470 },
seed: { x: 790, y: 520 },
output: { x: 1090, y: 48 }
}
export const NODE_TITLE_KEYS = {
model: 'hero.node.model',
clip: 'hero.node.clip',
vae: 'hero.node.vae',
lora: 'hero.node.lora',
seed: 'hero.node.seed',
output: 'hero.node.output'
} as const satisfies Record<WorkflowNodeId, TranslationKey>
export const nodeWidgets: Partial<Record<WorkflowNodeId, NodeWidget[]>> = {
model: [
{ name: 'unet_name', value: 'krea2_turbo_fp8_scaled', kind: 'combo' }
],
clip: [{ name: 'clip_name', value: 'qwen3vl_4b_fp8_scaled', kind: 'combo' }],
vae: [{ name: 'vae_name', value: 'qwen_image_vae', kind: 'combo' }],
lora: [
{ name: 'lora_name', value: 'krea2_darkbrush', kind: 'combo' },
{ name: 'strength_model', value: '0.80', kind: 'number' }
]
}
// Litegraph slot colors, so the wiring reads as the real ComfyUI canvas.
const WIRE_COLORS = {
model: '#b39ddb',
clip: '#ffd500',
vae: '#ff6e6e',
int: '#6a8bad'
} as const
type Axis = 'h' | 'v'
type Port = (r: Rect) => Point
const rightPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w, y: r.y + r.h * f })
const leftPort =
(f = 0.5): Port =>
(r) => ({ x: r.x, y: r.y + r.h * f })
function clampOffset(d: number): number {
return Math.min(Math.max(Math.abs(d) * 0.5, 55), 120)
}
// Soft cubic whose tangents follow the connected ports, so a wire between side
// ports departs horizontally even when the vertical gap dominates.
export function spline(s: Point, e: Point, axis: Axis): string {
if (axis === 'h') {
const off = Math.sign(e.x - s.x || 1) * clampOffset(e.x - s.x)
return `M ${s.x} ${s.y} C ${s.x + off} ${s.y} ${e.x - off} ${e.y} ${e.x} ${e.y}`
}
const off = Math.sign(e.y - s.y || 1) * clampOffset(e.y - s.y)
return `M ${s.x} ${s.y} C ${s.x} ${s.y + off} ${e.x} ${e.y - off} ${e.x} ${e.y}`
}
interface Connection {
from: WorkflowNodeId
to: WorkflowNodeId
fromPort: Port
toPort: Port
axis: Axis
color: string
}
export const connections: Connection[] = [
{
from: 'model',
to: 'lora',
fromPort: rightPort(0.7),
toPort: leftPort(0.35),
axis: 'h',
color: WIRE_COLORS.model
},
{
from: 'clip',
to: 'lora',
fromPort: rightPort(0.7),
toPort: leftPort(0.6),
axis: 'h',
color: WIRE_COLORS.clip
},
{
from: 'lora',
to: 'output',
fromPort: rightPort(0.4),
toPort: leftPort(0.14),
axis: 'h',
color: WIRE_COLORS.model
},
{
from: 'seed',
to: 'output',
fromPort: rightPort(0.45),
toPort: leftPort(0.19),
axis: 'h',
color: WIRE_COLORS.int
},
{
from: 'vae',
to: 'output',
fromPort: rightPort(0.7),
toPort: leftPort(0.24),
axis: 'h',
color: WIRE_COLORS.vae
}
]
export function computeWires(
anchors: Partial<Record<WorkflowNodeId, Rect>>
): Wire[] {
return connections.flatMap((c) => {
const fr = anchors[c.from]
const to = anchors[c.to]
if (!fr || !to) return []
const from = c.fromPort(fr)
const dest = c.toPort(to)
return [{ from, to: dest, color: c.color, d: spline(from, dest, c.axis) }]
})
}
// Drags are confined to the stage rect so every node stops at the edge
// instead of getting cut off.
export function clampNodePosition(
id: WorkflowNodeId,
point: Point,
height: number
): Point {
return {
x: clamp(point.x, 0, STAGE_W - NODE_W[id]),
y: clamp(point.y, 0, STAGE_H - height)
}
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { RENDER_COUNT, pickSeed, renderSrc } from './useHeroWorkflowRun'
describe('renderSrc', () => {
it('maps indices to zero-padded webp paths', () => {
expect(renderSrc(0)).toBe('/images/hero/renders/render-01.webp')
expect(renderSrc(RENDER_COUNT - 1)).toBe(
'/images/hero/renders/render-50.webp'
)
})
})
describe('pickSeed', () => {
it('never lands on the previous render bucket two runs in a row', () => {
const lastIndex = 7
// Force a seed that collides with the last render bucket.
const collidingRandom = () => (RENDER_COUNT + lastIndex) / 999_999_999
const seed = pickSeed(collidingRandom, lastIndex)
expect(seed % RENDER_COUNT).not.toBe(lastIndex)
})
it('keeps the seed unchanged when there is no collision', () => {
const seed = pickSeed(() => 0.5, null)
expect(seed).toBe(Math.floor(0.5 * 999_999_999))
})
})

View File

@@ -0,0 +1,124 @@
import { useRafFn } from '@vueuse/core'
import { ref } from 'vue'
import type { WorkflowNodeId } from './heroWorkflowGraph'
export const RENDER_COUNT = 50
export function renderSrc(index: number): string {
return `/images/hero/renders/render-${String(index + 1).padStart(2, '0')}.webp`
}
// The seed is what the user sees; the render shown is seed % RENDER_COUNT.
// Nudging the seed when it lands on the previous bucket guarantees a fresh
// image on every consecutive run.
export function pickSeed(
random: () => number,
lastIndex: number | null
): number {
const seed = Math.floor(random() * 999_999_999)
return seed % RENDER_COUNT === lastIndex ? seed + 1 : seed
}
// Fake execution timeline: loaders warm up quickly, then the sampler carries
// most of the run — mirroring how the real workflow feels in ComfyUI.
const RUN_STEPS: { id: WorkflowNodeId; duration: number }[] = [
{ id: 'model', duration: 600 },
{ id: 'clip', duration: 450 },
{ id: 'vae', duration: 400 },
{ id: 'lora', duration: 550 },
{ id: 'seed', duration: 300 },
{ id: 'output', duration: 1700 }
]
const TOTAL_DURATION = RUN_STEPS.reduce((sum, s) => sum + s.duration, 0)
export type RunPhase = 'idle' | 'running' | 'done'
export type NodeRunState = 'idle' | 'running' | 'done'
export function useHeroWorkflowRun() {
const phase = ref<RunPhase>('idle')
const seed = ref(52)
const activeNode = ref<WorkflowNodeId | null>(null)
const nodeProgress = ref(0)
const totalProgress = ref(0)
const outputSrc = ref<string | null>(null)
let elapsed = 0
let pendingIndex: number | null = null
let imageReady = false
const { pause, resume } = useRafFn(({ delta }) => advance(delta), {
immediate: false
})
function advance(delta: number) {
// Cap long frames (background tab) so the run never skips visibly.
elapsed += Math.min(delta, 100)
let start = 0
for (const step of RUN_STEPS) {
if (elapsed < start + step.duration) {
activeNode.value = step.id
nodeProgress.value = (elapsed - start) / step.duration
totalProgress.value = elapsed / TOTAL_DURATION
return
}
start += step.duration
}
if (!imageReady) {
// Hold just short of done until the render finishes downloading.
activeNode.value = 'output'
nodeProgress.value = 0.96
totalProgress.value = 0.96
return
}
pause()
phase.value = 'done'
activeNode.value = null
nodeProgress.value = 0
totalProgress.value = 1
outputSrc.value = pendingIndex === null ? null : renderSrc(pendingIndex)
}
function run() {
if (phase.value === 'running') return
const nextSeed = pickSeed(Math.random, pendingIndex)
seed.value = nextSeed
pendingIndex = nextSeed % RENDER_COUNT
imageReady = false
const image = new Image()
image.onload = () => {
imageReady = true
}
image.onerror = () => {
imageReady = true
}
image.src = renderSrc(pendingIndex)
elapsed = 0
totalProgress.value = 0
phase.value = 'running'
resume()
}
function nodeState(id: WorkflowNodeId): NodeRunState {
if (activeNode.value === id) return 'running'
if (phase.value === 'done') return 'done'
if (phase.value !== 'running') return 'idle'
const activeIndex = RUN_STEPS.findIndex((s) => s.id === activeNode.value)
const index = RUN_STEPS.findIndex((s) => s.id === id)
return index < activeIndex ? 'done' : 'idle'
}
return {
phase,
seed,
activeNode,
nodeProgress,
totalProgress,
outputSrc,
run,
nodeState
}
}
export type HeroWorkflowRun = ReturnType<typeof useHeroWorkflowRun>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { IconButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { iconButtonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: IconButtonVariants['variant']
size?: IconButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="icon-button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(iconButtonVariants({ variant, size }), className)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const iconButtonVariants = cva(
[
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
],
{
variants: {
variant: {
ghost:
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
},
size: {
sm: 'size-8',
default: 'size-10',
lg: 'size-14'
}
},
defaultVariants: {
variant: 'ghost',
size: 'default'
}
}
)
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>

View File

@@ -0,0 +1,75 @@
import { onMounted, ref } from 'vue'
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
type ClosedBanners = Record<string, boolean>
function readClosedBanners(): ClosedBanners {
try {
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
} catch {
return {}
}
}
function writeClosedBanners(value: ClosedBanners): void {
try {
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
} catch {
// Storage unavailable (private mode / quota) — dismissal just won't persist.
}
}
/** The stable part of a version key (everything before `_v<hash>`). */
function versionPrefix(version: string): string {
const idx = version.lastIndexOf('_v')
return idx === -1 ? version : version.slice(0, idx)
}
/**
* Client-side dismissal persisted in localStorage, keyed by a content-aware
* `version`. The banner renders visible in the static HTML (so non-dismissers
* see no pop-in); an inline pre-hydration script hides an already-dismissed
* banner before paint, and this composable then removes it from the DOM on mount.
*/
export function useBannerDismissal(version: string) {
const isVisible = ref(true)
onMounted(() => {
const stored = readClosedBanners()
const prefix = versionPrefix(version)
// Prune stale versions of THIS banner+locale; keep other banners/locales
// and the current version.
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
let pruned = false
for (const key of Object.keys(stored)) {
if (versionPrefix(key) !== prefix || key === version) {
cleaned[key] = stored[key]
} else {
pruned = true
}
}
if (pruned) writeClosedBanners(cleaned)
isVisible.value = !cleaned[version]
})
function close(): void {
isVisible.value = false
const stored = readClosedBanners()
stored[version] = true
writeClosedBanners(stored)
}
// Call once the close transition has finished. Sets the pre-paint hide signal
// so the banner doesn't flash back in on a ClientRouter (view-transition)
// navigation — where the inline <head> script does not re-run but <html>
// persists. Deferred to after the animation so the leave transition can play.
function persistHidden(): void {
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
}
return { isVisible, close, persistHidden }
}

View File

@@ -0,0 +1,84 @@
import type { ButtonVariants } from '../components/ui/button'
import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
// window gates on the last deploy, not the visitor's exact clock.
interface BannerLinkConfig {
readonly href: string
readonly titleKey: TranslationKey
readonly target?: boolean
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerConfig {
readonly id: string
readonly isActive: boolean
readonly startsAt?: string
readonly endsAt?: string
/** Empty/undefined = all locales. */
readonly targetLocales?: readonly Locale[]
/** v1 only supports 'sitewide'. */
readonly targetSections?: readonly string[]
readonly titleKey: TranslationKey
readonly descriptionKey?: TranslationKey
readonly link?: BannerLinkConfig
}
interface BannerLinkData {
readonly href: string
readonly title: string
readonly target?: '_blank'
readonly rel?: string
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerData {
readonly id: string
readonly title: string
readonly description?: string
readonly link?: BannerLinkData
}
export const bannerConfig: BannerConfig = {
id: 'announcement',
isActive: true,
targetSections: ['sitewide'],
titleKey: 'launches.banner.text',
link: {
href: '/mcp',
titleKey: 'launches.banner.cta',
buttonVariant: 'underlineLink'
}
}
/** Resolve a config's i18n keys into display strings for the given locale. */
export function getBannerData(
config: BannerConfig,
locale: Locale
): BannerData {
const { link } = config
const target = link?.target ? '_blank' : undefined
return {
id: config.id,
title: t(config.titleKey, locale),
description: config.descriptionKey
? t(config.descriptionKey, locale)
: undefined,
link: link
? {
href: link.href,
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),
buttonVariant: link.buttonVariant
}
: undefined
}
}

View File

@@ -53,6 +53,32 @@ const translations = {
en: 'Run your first workflow',
'zh-CN': '运行你的第一个工作流'
},
'hero.cta.cloud': {
en: 'Try it in ComfyUI Cloud',
'zh-CN': '在 ComfyUI Cloud 体验'
},
'hero.node.model': { en: 'Load Diffusion Model', 'zh-CN': '加载扩散模型' },
'hero.node.clip': { en: 'Load CLIP', 'zh-CN': '加载 CLIP' },
'hero.node.vae': { en: 'Load VAE', 'zh-CN': '加载 VAE' },
'hero.node.lora': { en: 'Load LoRA', 'zh-CN': '加载 LoRA' },
'hero.node.seed': { en: 'Seed', 'zh-CN': '种子' },
'hero.node.output': { en: 'Save Image', 'zh-CN': '保存图像' },
'hero.run': { en: 'Run', 'zh-CN': '运行' },
'hero.runAgain': { en: 'Run again', 'zh-CN': '再次运行' },
'hero.totalProgress': { en: 'Total', 'zh-CN': '总进度' },
'hero.output.hint': {
en: 'Press Run to generate an image with a fresh seed',
'zh-CN': '按下运行,用全新种子生成一张图像'
},
'hero.output.seed': { en: 'seed', 'zh-CN': '种子' },
'hero.output.alt': {
en: 'Generated image: a hand holding a martini glass surrounded by playful ink-sketch cartoon characters',
'zh-CN': '生成的图像:手持马提尼酒杯,周围环绕着俏皮的手绘卡通角色'
},
'hero.output.openCloud': {
en: 'Open in ComfyUI Cloud',
'zh-CN': '在 ComfyUI Cloud 中打开'
},
// ProductShowcaseSection
'showcase.subtitle1': {
@@ -3983,12 +4009,12 @@ const translations = {
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
en: 'Now turn your agent into a creative technologist.',
'zh-CN': '现在,让你的智能体成为创意技术专家。'
},
'launches.banner.cta': {
en: 'Join livestream',
'zh-CN': '加入直播'
en: 'Start Comfy MCP',
'zh-CN': '启动 Comfy MCP'
},
// Launches page (/launches) — closing CTA

View File

@@ -5,6 +5,14 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
@@ -124,6 +141,25 @@ const websiteJsonLd = {
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -137,8 +173,16 @@ const websiteJsonLd = {
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<main>
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -13,7 +12,6 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -70,10 +70,14 @@
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-deep-plum: #2b2040;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
--color-transparency-ink-t80: rgb(33 25 39 / 0.8);
--color-hero-node: #1f2026;
--color-hero-node-inset: #16171c;
--color-hero-exec: #3d7eff;
--font-formula: 'PP Formula', sans-serif;
--font-formula-narrow: 'PP Formula Narrow', sans-serif;
--text-3\.5xl: 2rem;
@@ -93,6 +97,14 @@
initial-value: 0deg;
}
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
script in BaseLayout head) — prevents any flash before Vue hydrates.
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
keep them in sync. */
[data-banner-dismissed] [data-slot='announcement-banner'] {
display: none;
}
@keyframes border-angle-spin {
to {
--border-angle: 360deg;
@@ -215,6 +227,62 @@
opacity: 0;
}
/* ComfyUI-style canvas dot grid behind the hero workflow. */
.hero-dot-grid {
background-image: radial-gradient(
rgb(255 255 255 / 0.07) 1px,
transparent 1.5px
);
background-size: 26px 26px;
}
/* Workflow connectors: a short bright dash flows along each wire while the
workflow runs. path-length is normalized to 1 so the dash travels at a
consistent rate regardless of wire length. */
.hero-wire-pulse {
stroke-dashoffset: 1;
opacity: 0;
transition: opacity 0.3s ease;
}
.hero-wire-active .hero-wire-pulse {
opacity: 0.9;
animation: hero-wire-flow 1.2s linear infinite;
}
@keyframes hero-wire-flow {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
/* Freshly generated renders sharpen into place; the previous render fades
beneath. Reduced-motion users get a near-instant swap via the global
override below. */
.hero-render-enter-active {
transition:
opacity 0.5s ease,
filter 0.5s ease;
}
.hero-render-enter-from {
opacity: 0;
filter: blur(12px);
}
.hero-render-leave-active {
position: absolute;
inset: 0;
transition: opacity 0.35s ease;
}
.hero-render-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ArrowRight, X } from '@lucide/vue'
import type { BannerData } from '../../config/banner'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import IconButton from '@/components/ui/icon-button/IconButton.vue'
import { useBannerDismissal } from '../../composables/useBannerDismissal'
const {
data,
version,
locale = 'en'
} = defineProps<{
data: BannerData
version: string
locale?: Locale
}>()
const { isVisible, close, persistHidden } = useBannerDismissal(version)
</script>
<template>
<Transition name="banner-collapse" @after-leave="persistHidden">
<div v-if="isVisible" class="banner-collapse grid">
<div class="min-h-0 overflow-hidden">
<div
data-slot="announcement-banner"
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
style="
background: linear-gradient(
90deg,
var(--color-primary-comfy-plum) 0%,
var(--color-secondary-deep-plum) 53.85%,
var(--color-secondary-mauve) 100%
);
"
>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
<p
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
>
{{ data.title }}
<span v-if="data.description" class="text-primary-warm-white/80">
{{ data.description }}
</span>
</p>
<Button
v-if="data.link"
as="a"
:href="data.link.href"
:target="data.link.target"
:rel="data.link.rel"
:variant="data.link.buttonVariant ?? 'underlineLink'"
size="sm"
>
{{ data.link.title }}
<template #append>
<ArrowRight class="size-4" />
</template>
</Button>
</div>
<div class="flex flex-1 justify-end">
<IconButton
type="button"
:aria-label="t('nav.close', locale)"
@click="close"
>
<X class="size-5" aria-hidden="true" />
</IconButton>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
leave (dismiss) runs, since the banner renders present in the static HTML. */
.banner-collapse {
grid-template-rows: 1fr;
}
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition:
grid-template-rows 300ms ease,
opacity 250ms ease;
}
.banner-collapse-enter-from,
.banner-collapse-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition: none;
}
}
</style>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import type { EvaluableBanner } from './banner'
import { createBannerVersion, evaluateBannerVisibility } from './banner'
const base: EvaluableBanner = {
isActive: true,
targetSections: ['sitewide']
}
const ctx = {
currentLocale: 'en',
currentSection: 'sitewide',
now: new Date('2026-07-06T00:00:00Z')
}
describe('evaluateBannerVisibility', () => {
it('shows an active, untargeted, sitewide banner', () => {
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
})
it('hides when inactive', () => {
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
false
)
})
it('hides before startsAt and shows within the window', () => {
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
ctx
)
).toBe(false)
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
ctx
)
).toBe(true)
})
it('hides after endsAt', () => {
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
).toBe(true)
})
it('treats an empty targetLocales as "all locales"', () => {
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
true
)
})
it('hides when targetLocales excludes the current locale', () => {
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
).toBe(true)
})
it('hides when targetSections does not include the current section', () => {
expect(
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
).toBe(false)
})
it('hides when targetSections is absent (nothing to match)', () => {
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
})
})
describe('createBannerVersion', () => {
const content = {
id: 'announcement',
title: 'Join the live stream',
link: { href: 'https://x', title: 'Join' }
}
it('is deterministic for identical content', () => {
expect(createBannerVersion(content, 'en')).toBe(
createBannerVersion(content, 'en')
)
})
it('encodes the banner id and locale in the key', () => {
expect(createBannerVersion(content, 'en')).toMatch(
/^announcement_en_v-?\d+$/
)
})
it('changes when the copy changes', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion({ ...content, title: 'New copy' }, 'en')
)
})
it('differs per locale so one locale edit does not re-show another', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion(content, 'zh-CN')
)
})
})

View File

@@ -0,0 +1,87 @@
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
// trivially unit-testable. Locale/section are plain strings on purpose.
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
// the useBannerDismissal composable, and the CSS selector in global.css must all
// agree on these literals — keep them here as the single source of truth.
export const BANNER_STORAGE_KEY = 'closedBanners'
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
export interface BannerVisibilityContext {
currentLocale: string
currentSection: string
now: Date
}
export interface EvaluableBanner {
isActive: boolean
startsAt?: string
endsAt?: string
targetLocales?: readonly string[]
targetSections?: readonly string[]
}
/**
* Server/build-time visibility gate. Returns false on the FIRST failing check,
* in order: active flag → start window → end window → locale targeting →
* section targeting. An empty/absent `targetLocales` means "all locales".
*/
export function evaluateBannerVisibility(
banner: EvaluableBanner,
ctx: BannerVisibilityContext
): boolean {
if (!banner.isActive) return false
if (
banner.startsAt &&
ctx.now.getTime() < new Date(banner.startsAt).getTime()
)
return false
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
return false
const targetLocales = banner.targetLocales ?? []
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
return false
const targetSections = banner.targetSections ?? []
if (!targetSections.includes(ctx.currentSection)) return false
return true
}
interface BannerLinkContent {
href: string
title: string
target?: string
rel?: string
buttonVariant?: string
}
export interface BannerVersionContent {
id: string
title: string
description?: string
link?: BannerLinkContent
}
/**
* Content-aware version key. Editing the copy changes the hash, so a previously
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
*/
export function createBannerVersion(
content: BannerVersionContent,
locale: string
): string {
const contentString = JSON.stringify({
locale,
title: content.title,
description: content.description,
link: content.link
})
let hash = 0
for (const char of contentString) {
hash = Math.imul(hash, 31) + char.charCodeAt(0)
}
return `${content.id}_${locale}_v${hash}`
}

View File

@@ -12,6 +12,11 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -42,6 +47,11 @@ export type {
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelJobsData,
CancelJobsError,
CancelJobsErrors,
CancelJobsResponse,
CancelJobsResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -84,6 +94,11 @@ export type {
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
CreateDesktopLoginCodeResponse,
CreateDesktopLoginCodeResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
@@ -186,12 +201,31 @@ export type {
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
DesktopLoginCodeExchangeResponse,
DesktopLoginCodeRedeemRequest,
DesktopLoginCodeRedeemResponse,
DownloadExportData,
DownloadExportError,
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
ExchangeDesktopLoginCodeErrors,
ExchangeDesktopLoginCodeResponse,
ExchangeDesktopLoginCodeResponses,
ExchangeTokenData,
ExchangeTokenError,
ExchangeTokenErrors,
@@ -230,6 +264,11 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetContentData,
GetAssetContentError,
GetAssetContentErrors,
GetAssetContentResponse,
GetAssetContentResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
@@ -303,6 +342,11 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -345,8 +389,6 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
@@ -556,6 +598,7 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -589,6 +632,8 @@ export type {
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsCancelRequest,
JobsCancelResponse,
JobsListResponse,
JobStatusResponse,
JwkKey,
@@ -627,7 +672,19 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
ListSecretProvidersErrors,
ListSecretProvidersResponse,
ListSecretProvidersResponses,
ListSecretsData,
ListSecretsError,
ListSecretsErrors,
@@ -775,6 +832,17 @@ export type {
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RedeemDesktopLoginCodeData,
RedeemDesktopLoginCodeError,
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -785,6 +853,11 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
@@ -808,6 +881,8 @@ export type {
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretProvider,
SecretProvidersResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
@@ -819,6 +894,8 @@ export type {
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -848,6 +925,10 @@ export type {
TaskEntry,
TaskResponse,
TasksListResponse,
TeamCreditStop,
TeamCreditStopPrice,
TeamCreditStops,
TeamCreditStopSummary,
UpdateAssetData,
UpdateAssetError,
UpdateAssetErrors,
@@ -865,6 +946,7 @@ export type {
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -895,6 +977,11 @@ export type {
UpdateWorkspaceData,
UpdateWorkspaceError,
UpdateWorkspaceErrors,
UpdateWorkspaceMemberRoleData,
UpdateWorkspaceMemberRoleError,
UpdateWorkspaceMemberRoleErrors,
UpdateWorkspaceMemberRoleResponse,
UpdateWorkspaceMemberRoleResponses,
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,

File diff suppressed because it is too large Load Diff

View File

@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
/**
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
*/
export const zHistoryEventRequest = z.object({
user_id: z.string().optional(),
workspace_id: z.string().optional(),
event_type: z.string().min(1),
event_id: z.string().min(1),
params: z.record(z.unknown()).optional(),
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
customer_ref: z.string().optional(),
timestamp: z.string().datetime().optional()
})
/**
* Response after recording partner usage data.
*/
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
})
/**
* Response after successfully resubscribing to a billing plan.
* Response after accepting a resubscribe request.
*/
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active']),
status: z.enum(['active', 'pending']),
message: z.string().optional()
})
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
*/
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
team_credit_stop_id: z.string().optional(),
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
idempotency_key: z.string().optional(),
return_url: z.string().optional(),
cancel_url: z.string().optional()
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
'STANDARD',
'CREATOR',
'PRO',
'FOUNDERS_EDITION'
'FOUNDERS_EDITION',
'TEAM'
])
/**
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
/**
* Pre/post-discount price for a team credit stop, in cents.
*/
export const zTeamCreditStopPrice = z.object({
list_price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A selectable preset on the team pricing slider. Echoed on subscribe via
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
* RAW monthly credit count (not cents). Save% is derived by the FE as
* (list_price_cents - price_cents) / list_price_cents.
*
*/
export const zTeamCreditStop = z.object({
id: z.string(),
credits: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
monthly: zTeamCreditStopPrice,
yearly: zTeamCreditStopPrice
})
/**
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
*/
export const zTeamCreditStops = z.object({
default_stop_index: z.number().int(),
stops: z.array(zTeamCreditStop)
})
/**
* Reason why a plan is unavailable
*/
@@ -773,7 +841,50 @@ export const zPlan = z.object({
*/
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan)
plans: z.array(zPlan),
team_credit_stops: zTeamCreditStops.optional()
})
/**
* The team credit stop a workspace is currently subscribed to: the
* per-workspace slider choice recorded at subscribe time
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
* catalog, not the subscription row. Returned on GET /api/billing/status
* for per-credit Team plans (BE-1254).
*
*/
export const zTeamCreditStopSummary = z.object({
id: z.string(),
credits_monthly: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
stop_usd: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
*/
export const zSecretProvider = z.object({
id: z.string()
})
/**
* The providers available to the authenticated user in the current workspace.
*/
export const zSecretProvidersResponse = z.object({
data: z.array(zSecretProvider)
})
/**
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
})
/**
* A single billing event such as a charge, credit, or adjustment.
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
*/
export const zBillingEvent = z.object({
event_type: z.string(),
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
billing_status: zBillingStatus.optional(),
has_funds: z.boolean(),
cancel_at: z.string().datetime().optional(),
renewal_date: z.string().datetime().optional()
renewal_date: z.string().datetime().optional(),
team_credit_stop: zTeamCreditStopSummary.nullable()
})
/**
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
redirect_uri: z.string().url(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
customer_id: z.string().min(1)
})
/**
* The personal workspace's provisioned billing identity.
*/
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
workspace_id: z.string(),
stripe_customer_id: z.string(),
metronome_customer_id: z.string(),
metronome_contract_id: z.string()
})
/**
* The caller's already-resolved legacy (comfy-api) customer identity. When
* present and carrying provider IDs, provisioning ATTACHES this identity to
* the personal workspace (sharing the existing balance and subscription)
* instead of minting a net-new empty customer. Omit (or send with no
* provider IDs) for a free user with nothing to attach — provisioning then
* creates net-new. This closes the create-new-before-attach gap: a caller
* that already knows the legacy identity hands it over so the very first
* provisioning is an attach.
*
*/
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
stripe_customer_id: z.string().optional(),
metronome_customer_id: z.string().optional(),
metronome_contract_id: z.string().optional(),
has_funds: z.boolean().optional(),
subscription_tier: z.string().optional(),
legacy_stripe_subscription_id: z.string().optional(),
legacy_comfy_user_id: z.string().optional()
})
/**
* Request body for ensuring a user's personal workspace carries a fully
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
* with the already canonical-resolved user identity.
*
*/
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
user_id: z.string().min(1),
email: z.string().email().min(1),
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
})
/**
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
* no mappings exist (not an error — callers can treat empty as "unknown
* canonical").
*
*/
export const zListLinkedFirebaseUidsResponse = z.object({
firebase_uids: z.array(z.string())
})
/**
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
*/
export const zListLinkedFirebaseUidsRequest = z.object({
comfy_user_id: z.string().min(1)
})
/**
* Response confirming the validity and scope of a workspace API key.
*/
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(['owner', 'member']),
joined_at: z.string().datetime()
joined_at: z.string().datetime(),
is_original_owner: z.boolean()
})
/**
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
pagination: zPaginationInfo
})
/**
* Request body for changing a workspace member's role.
*/
export const zUpdateMemberRoleRequest = z.object({
role: z.enum(['owner', 'member'])
})
/**
* Request body for updating an existing workspace's settings.
*/
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
/**
* Exchange poll result. Pending until the code is redeemed in the browser.
*/
export const zDesktopLoginCodeExchangeResponse = z.object({
status: z.enum(['pending', 'complete']),
custom_token: z.string().optional()
})
/**
* Request to exchange a redeemed login code for a custom token.
*/
export const zDesktopLoginCodeExchangeRequest = z.object({
code: z.string(),
code_verifier: z.string().min(43).max(128)
})
/**
* Result of redeeming a desktop login code.
*/
export const zDesktopLoginCodeRedeemResponse = z.object({
status: z.enum(['redeemed'])
})
/**
* Request to claim a desktop login code for the authenticated user.
*/
export const zDesktopLoginCodeRedeemRequest = z.object({
code: z.string()
})
/**
* A freshly minted desktop login code and its polling parameters.
*/
export const zDesktopLoginCodeCreateResponse = z.object({
code: z.string(),
expires_in: z.number().int(),
poll_interval: z.number().int()
})
/**
* Request to mint a desktop login code.
*/
export const zDesktopLoginCodeCreateRequest = z.object({
installation_id: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.optional(),
platform: z.string().min(1).max(32),
app_version: z.string().min(1).max(64),
code_challenge: z.string().min(43).max(128)
})
/**
* Abbreviated workspace metadata used in list responses.
*/
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
pagination: zPaginationInfo
})
/**
* Result of authorizing a legal-hold release on a user's deletion.
*/
export const zReleaseHoldResponse = z.object({
firebase_id: z.string(),
released: z.boolean(),
message: z.string()
})
/**
* Current status of a user data deletion request.
*/
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
/**
* Response for POST /api/jobs/cancel.
*/
export const zJobsCancelResponse = z.object({
cancelled: z.array(z.string())
})
/**
* Request to cancel multiple jobs by ID.
*/
export const zJobsCancelRequest = z.object({
job_ids: z.array(z.string().uuid()).min(1).max(100)
})
/**
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
*/
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
path: z.object({
job_id: z.string().uuid()
}),
query: z.never().optional()
query: z
.object({
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
})
.optional()
})
/**
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
*/
export const zCancelJobResponse = zJobCancelResponse
export const zCancelJobsData = z.object({
body: zJobsCancelRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - cancel requests dispatched (or jobs were already terminal)
*/
export const zCancelJobsResponse = zJobsCancelResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
*/
export const zCreateSecretResponse = zSecretResponse
export const zListSecretProvidersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zListSecretProvidersResponse = zSecretProvidersResponse
export const zDeleteSecretData = z.object({
body: z.never().optional(),
path: z.object({
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
*/
export const zExchangeTokenResponse2 = zExchangeTokenResponse
export const zCreateDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeCreateRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Login code created
*/
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
export const zRedeemDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeRedeemRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Code redeemed (or already redeemed by the same user)
*/
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
export const zExchangeDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeExchangeRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Pending (not yet redeemed) or complete with a custom token
*/
export const zExchangeDesktopLoginCodeResponse =
zDesktopLoginCodeExchangeResponse
export const zGetJwksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zUpdateWorkspaceMemberRoleData = z.object({
body: zUpdateMemberRoleRequest,
path: z.object({
userId: z.string()
}),
query: z.never().optional()
})
/**
* Member role updated
*/
export const zUpdateWorkspaceMemberRoleResponse = zMember
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zAdminDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully deleted
*/
export const zAdminDeleteHubWorkflowResponse = z.void()
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReleaseDeletionHoldData = z.object({
body: z.object({
firebase_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Release authorized; the deletion workflow will proceed
*/
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zGetHistoryEventsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
workspace_id: z.string().optional(),
user_id: z.string().optional(),
event_type: z.string().optional(),
start_date: z.string().datetime().optional(),
end_date: z.string().datetime().optional(),
page: z.number().int().optional(),
limit: z.number().int().optional()
})
.optional()
})
/**
* Paginated cloud history events for the workspace
*/
export const zGetHistoryEventsResponse = zBillingEventsResponse
export const zReportHistoryEventData = z.object({
body: zHistoryEventRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* History event recorded successfully
*/
export const zReportHistoryEventResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zListLinkedFirebaseUidsData = z.object({
body: zListLinkedFirebaseUidsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Linked Firebase UIDs (possibly empty list)
*/
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
export const zEnsureWorkspaceBillingProvisionedData = z.object({
body: zEnsureWorkspaceBillingProvisionedRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* The workspace's provisioned billing identity
*/
export const zEnsureWorkspaceBillingProvisionedResponse2 =
zEnsureWorkspaceBillingProvisionedResponse
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
query: z.never().optional()
})
export const zShortLinkRedirectData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
query: z.never().optional()
})
export const zGetLegacyAssetContentData = z.object({
export const zGetAssetContentData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
query: z
.object({
disposition: z.enum(['inline', 'attachment']).optional()
})
.optional()
})
/**
* Asset content stream (local runtime streams the bytes directly)
*/
export const zGetAssetContentResponse = z.string()
export const zGetLegacyViewMetadataData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "*.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "vitest.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -35,10 +35,10 @@
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
@@ -82,7 +82,7 @@
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -95,10 +95,10 @@
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE

View File

@@ -7,7 +7,7 @@ import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockTurnstileToken = ref('')
const mockTurnstileUnavailable = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
// useTurnstile.test.ts); this fake just wires token/unavailable through to
// `waiting` the same way so SignUpForm's submit gating can be exercised.
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
enabled: mockTurnstileEnabled
}),
useTurnstileGate: () => ({
token: mockTurnstileToken,
unavailable: mockTurnstileUnavailable,
waiting: computed(
() =>
mockTurnstileEnabled.value &&
!mockTurnstileToken.value &&
!mockTurnstileUnavailable.value
)
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
// the way a solved challenge (or a broken/slow widget) would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
emits: ['update:token', 'update:unavailable'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
emitTurnstileUnavailable = (unavailable: boolean) =>
emit('update:unavailable', unavailable)
return () => null
}
})
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockTurnstileToken.value = ''
mockTurnstileUnavailable.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
emitTurnstileUnavailable = undefined
})
afterEach(() => {
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
// Regression coverage for the shadow-mode race: previously submit was only
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
// ahead of the async Cloudflare challenge and reached the backend with an
// empty token. Gating now depends only on whether the widget is enabled
// (shadow or enforce both render it), so both modes behave identically here.
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
it('disables the submit button until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
it('does not emit submit while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
it('emits submit with the token once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileUnavailable!(true)
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)

View File

@@ -33,10 +33,11 @@
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
v-model:unavailable="turnstileUnavailable"
/>
<small
v-show="submitBlockedByTurnstile"
v-show="waitingForTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
@@ -51,11 +52,9 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid || submitBlockedByTurnstile"
:disabled="!$form.valid || waitingForTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
"
>
{{ t('auth.signup.signUpButton') }}
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
@@ -86,25 +85,21 @@ const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const { enabled: turnstileEnabled } = useTurnstile()
const {
token: turnstileToken,
unavailable: turnstileUnavailable,
waiting: waitingForTurnstile
} = useTurnstileGate(turnstileEnabled)
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid && !submitBlockedByTurnstile.value) {
if (event.valid && !waitingForTurnstile.value) {
emit(
'submit',
event.values as SignUpData,

View File

@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
// hung challenge) must eventually tell the parent it cannot be relied on,
// so submission can fall back instead of blocking a legitimate signup
// forever.
describe('unavailable fallback', () => {
it('reports unavailable when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted } = renderWidget()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('reports unavailable on a challenge error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('clears the unavailable fallback once a token is solved', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
})
it('falls back once the widget fails to resolve within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
// Let the onMounted hook's `await loadTurnstile()` microtask settle
// and render() run, without yet advancing to the timeout itself.
await vi.advanceTimersByTimeAsync(0)
expect(options()).toBeDefined()
expect(emitted()['update:unavailable']).toBeUndefined()
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
it('does not fall back once a token arrives before the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
options()!.callback!('token-abc')
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
// Establish a solved, available widget: an initial error marks it
// unavailable, then solving a challenge clears that (the same
// transition the existing "clears the unavailable fallback" test
// verifies), so the expiry below is the only thing driving fallback.
options()!['error-callback']!()
options()!.callback!('token-abc')
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
// The token later expires (e.g. tab backgrounded past its ~300s
// lifetime) without the widget itself erroring.
options()!['expired-callback']!()
await vi.advanceTimersByTimeAsync(0)
// A fresh challenge was requested, but nothing solves it before the
// re-armed load timeout elapses, so submission must eventually be
// unblocked rather than staying stuck forever.
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -12,6 +12,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
/**
* Set true whenever the widget cannot be relied on to ever produce a token:
* the Cloudflare script failed to load, the rendered challenge errored out,
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
* uses this to stop waiting on a token so a broken/slow widget (network
* issue, ad-blocker, CDN outage) can never permanently block signup.
*/
const unavailable = defineModel<boolean>('unavailable', { default: false })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
/** How long to wait for the widget to resolve before falling back. */
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
() => {
unavailable.value = true
},
TURNSTILE_LOAD_TIMEOUT_MS,
{ immediate: false }
)
const clearToken = () => {
token.value = ''
}
@@ -46,12 +65,18 @@ const reset = () => {
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A widget that renders can request a fresh challenge, so give it
// another chance before falling back again.
unavailable.value = false
armTimeout()
}
}
defineExpose({ reset })
onMounted(async () => {
armTimeout()
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
@@ -64,23 +89,37 @@ onMounted(async () => {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
clearLoadTimeout()
errorMessage.value = ''
unavailable.value = false
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A solved token can expire on its own (e.g. the tab was
// backgrounded past the token's ~300s lifetime) without the widget
// ever erroring, so proactively request a fresh challenge and
// re-arm the load timeout in case it doesn't resolve in time.
armTimeout()
}
},
'error-callback': () => {
clearToken()
clearLoadTimeout()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
clearLoadTimeout()
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
}
})

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
useTurnstile,
useTurnstileGate
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
})
})
})
describe('useTurnstileGate', () => {
it('waits while enabled with no token yet', () => {
const { waiting } = useTurnstileGate(ref(true))
expect(waiting.value).toBe(true)
})
it('never waits while disabled', () => {
const { waiting } = useTurnstileGate(ref(false))
expect(waiting.value).toBe(false)
})
it('stops waiting once a token arrives', () => {
const { token, waiting } = useTurnstileGate(ref(true))
token.value = 'token-abc'
expect(waiting.value).toBe(false)
})
it('stops waiting once the widget reports itself unavailable', () => {
const { unavailable, waiting } = useTurnstileGate(ref(true))
unavailable.value = true
expect(waiting.value).toBe(false)
})
it('clears stale token/unavailable state when enabled turns off', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
token.value = 'stale-token'
unavailable.value = true
enabled.value = false
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
// Regression coverage: the reset used to only run on the enabled->disabled
// transition, so state written while the widget was briefly disabled could
// survive into the next enabled widget instance.
it('clears stale token/unavailable state when enabled turns back on', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
enabled.value = false
await nextTick()
token.value = 'stale-token'
unavailable.value = true
enabled.value = true
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -42,3 +43,31 @@ export function useTurnstile() {
return { mode, siteKey, enabled, enforced }
}
/**
* Submit-gating state for the signup form's Turnstile widget: a token/
* unavailable pair, plus `waiting`, which is true while a real token is still
* needed. Waits in both shadow and enforce mode (`enabled`), not just
* `enforced`, so shadow mode's token can't race the async Cloudflare
* challenge; falls back open once the widget reports `unavailable` so a
* broken/slow load can never permanently block signup.
*
* `token`/`unavailable` reset on every `enabled` transition, in either
* direction, so state from a previous widget instance can never leak into a
* freshly (re-)rendered one.
*/
export function useTurnstileGate(enabled: Ref<boolean>) {
const token = ref('')
const unavailable = ref(false)
const waiting = computed(
() => enabled.value && !token.value && !unavailable.value
)
watch(enabled, () => {
token.value = ''
unavailable.value = false
})
return { token, unavailable, waiting }
}

View File

@@ -195,6 +195,46 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('platform axes (client / deployment)', () => {
afterEach(() => {
delete window.__comfyDesktop2
})
it('registers client=web and deployment=cloud in a plain browser', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
client: 'web',
deployment: 'cloud'
})
})
it('registers client=desktop when the desktop preload bridge is present', async () => {
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: { capture: vi.fn() }
}
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
client: 'desktop',
deployment: 'cloud'
})
})
it('registers platform axes before flushing pre-init queued events', async () => {
const provider = createProvider()
provider.trackSignupOpened()
await vi.dynamicImportSettled()
const registerOrder = hoisted.mockRegister.mock.invocationCallOrder[0]
const captureOrder = hoisted.mockCapture.mock.invocationCallOrder[0]
expect(registerOrder).toBeLessThan(captureOrder)
})
})
describe('desktop entry capture', () => {
function setLocation(search: string): void {
Object.defineProperty(window.location, 'search', {
@@ -208,12 +248,20 @@ describe('PostHogTelemetryProvider', () => {
setLocation('')
})
// The platform-axes register (client/deployment) always fires, so these
// assert no register call carrying desktop-entry attribution props.
function desktopEntryRegisterCalls(): unknown[][] {
return hoisted.mockRegister.mock.calls.filter(
([props]) => props && 'source_app' in (props as Record<string, unknown>)
)
}
it('does not register desktop props when utm_source is absent', async () => {
setLocation('')
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
expect(desktopEntryRegisterCalls()).toHaveLength(0)
})
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
@@ -221,7 +269,7 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
expect(desktopEntryRegisterCalls()).toHaveLength(0)
})
it('registers source_app and desktop_device_id when arriving from desktop', async () => {

View File

@@ -143,6 +143,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
before_send: createPostHogBeforeSend()
})
this.isInitialized = true
// Before flushEventQueue so pre-init events also carry the
// platform super properties.
this.registerPlatformProps()
this.flushEventQueue()
this.registerDesktopEntryProps()
@@ -285,6 +288,18 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
)
}
private registerPlatformProps(): void {
if (!this.posthog) return
try {
this.posthog.register({
client: window.__comfyDesktop2 ? 'desktop' : 'web',
deployment: 'cloud'
})
} catch (error) {
console.error('Failed to register platform props:', error)
}
}
private registerDesktopEntryProps(): void {
if (!this.posthog) return
const props = readDesktopEntryProps()