Compare commits

..

74 Commits

Author SHA1 Message Date
Nathaniel Parson Koroso
1c088f52cc docs(detection-proof): team explainer for reviewers
Short, reviewer-facing companion to DETECTION_PROOF.md: why a green suite isn't
proof, the falsification method, the break-to-signature table, and how to read
the 23 failed / 2 skipped / 61 passed result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:50:23 -07:00
Nathaniel Parson Koroso
c443aa3476 detection-proof: 10 deliberate breaks across every suite surface + CI break-packs step
Never-merge demo (PR #13534). One real regression per surface the custom-node
CI suite guards - mount v1/v2, persistence, wiring by type and by drag,
execution, load hook, and (via a CI step that patches the cloned packs) pack
console/runtime/registration. All live at once so a single run reds across
every tier with an attributable signature. See DETECTION_PROOF.md for the
break-to-signature matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:50:23 -07:00
Nathaniel Parson Koroso
6783d5ac18 Merge branch 'main' into nathaniel/custom-node-e2e-suite
Bring the custom-node suite branch current with main (37 commits) so the
detection-proof demo can rebase cleanly onto an up-to-date primary.
2026-07-10 15:42:52 -07:00
Nathaniel Parson Koroso
4962589a3e ci(custom-nodes): unpin backend, auto-take latest ComfyUI master
The v0.26.2 pin was a stopgap for a transient master boot regression. The
real cause (an uncaught vue-i18n compile throw on a pack node i18n value with
a literal '@') is now fixed in st() (src/i18n.ts), and current master boots
clean, so the gate tracks latest again. Pack-vs-latest drift remains the
nightly canary's job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:08:35 -07:00
Nathaniel Parson Koroso
03f2b6bd89 fix(custom-nodes): boot on newer ComfyUI backends
Two backend-drift failures that took the custom-node e2e suite 100% red on
ComfyUI master (green on v0.26.2):

1. st() (src/i18n.ts) compiled custom-node i18n values via vue-i18n's t().
   A pack node whose translated value contains a literal '@' (vue-i18n
   linked-message syntax) throws SyntaxError: Invalid linked format at
   compile time. st() had no catch and runs on the boot critical path
   (getNodeDefs -> registerNodes -> comfyApp.setup(), before window.app is
   assigned), so one bad pack message aborted app boot and window.app.
   extensionManager never got set -> every test timed out at waitForAppReady.
   Now st() falls back to the raw string when compilation throws.

2. dismissTemplatesDialog hard-waited (no timeout) for the templates dialog,
   which newer ComfyUI no longer auto-opens; now tolerant of its absence.

Verified: pinned to the exact failing backend SHA, the suite goes 86 passed /
0 failed (was 100% red at boot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:47:29 -07:00
Nathaniel Parson Koroso
7164b844ff ci(custom-nodes): pin backend to ComfyUI v0.26.2 for a deterministic gate
Unpinned comfyanonymous/ComfyUI master let a core boot regression (0.27.0
hangs frontend app-init before window.app is set) turn the whole custom-node
suite red through no fault of the frontend. Add an optional comfyui_ref input
to setup-comfyui-server (default unchanged = master) and pin only this job.
Pack-vs-latest-ComfyUI drift stays the nightly canary's concern (task #24).
2026-07-09 19:18:38 -07:00
Nathaniel Parson Koroso
f9dbb6b9d8 Merge remote-tracking branch 'origin/main' into nathaniel/custom-node-e2e-suite 2026-07-09 14:26:03 -07:00
Nathaniel Parson Koroso
424e85ebc0 docs(detection-proof): final falsification-pass sync - executed matrix, scoped-break rationale, registry self-heal finding 2026-07-09 11:52:52 -07:00
Nathaniel Parson Koroso
7203fdbb03 docs(detection-proof): sync rows 8-10 to CI-step delivery (not forks) and all-live commit design 2026-07-08 19:15:58 -07:00
Nathaniel Parson Koroso
baa2e046b7 docs(custom-nodes): Detection Proof accuracy pass
Pack-mode breaks are delivered via manifest pin swaps (CI clones packs at
pins, so in-repo pack-file edits cannot reach it); corpus-derived red
messages promise the tier and failure class, not byte-identical offender
text; remaining citations and mechanism descriptions tightened to what the
code and captured runs actually show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:19:11 -07:00
Nathaniel Parson Koroso
0cc21040c4 docs(custom-nodes): correct Detection Proof citations after a hallucination audit
Every quoted red message re-verified against its original run log (10/10
match). Seven citation corrections from the audit: the iTools missing-
button tickets are Nodes 2.0 regressions and move to the v2 mount row
(the v1 row is now honestly class-only); the SAM3 hidden-values tickets
are removed (extras-exposed class, which the mount tier tolerates by
design, so citing them overclaimed coverage); the persistence row now
cites the verified defaultInput migration regression (widgets reverting
to socket-only on reload) that open PR #12279 fixes, instead of a live
widget-interaction ticket; the links-type and serialization rows drop
borrowed tickets and state their class plainly; the drag row's tickets
are labeled nearest-symptom family; the two expansion bullets now cite
the committed pure-spec catches instead of an unproven live-sweep catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:46:21 -07:00
Nathaniel Parson Koroso
d65d853227 test(custom-nodes): per-test backend isolation via an afterEach drain to idle
Every test gets a fresh page but all tests share one backend, locally and
on CI alike (the CI job is deliberately unsharded). A test ending while
its prompt still executed left that work running, and the next test's
fresh page connected mid-execution and inherited its async errors or its
busy queue. Drain the backend to idle in an afterEach in all four
backend-running specs, while the finishing test's own page is still open
so late events land there: no test can affect the next.

The drain helper moves to the shared fixture util (drainBackendToIdle,
byte-identical body); the auto-run tier's queue guard and runBatch
post-timeout drain rewire to it with their explicit budgets. The hooks
use a 10s budget: a no-op when already idle, and a backend still busy
past it is wedged, which the auto-run tier's 150s guard surfaces with
the restart diagnostic.

DETECTION_PROOF.md's caveat is rewritten to match, and a false claim
that CI shards one backend per pack is corrected in every location
(code comments and doc): the CI job runs the whole suite against one
fresh backend on an unloaded runner, which is why executions stay
inside their budgets there.

Empirical: a full-suite run with the hook eliminated the cross-test
bleed class entirely (zero mount/save-reload/core-smoke console or
overlay failures, previously 3-5 per run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:14:14 -07:00
Nathaniel Parson Koroso
08106db082 test(custom-nodes): make the local full run idempotent (drain, foreign-noise filter, slow-node budgets)
The suite runs all 7 packs' execution tiers against one shared backend
locally (CI shards one backend per pack). Serial execution created three
distinct cross-test contaminations that failed a different set of packs
each run; each is now fixed at its mechanism:

- Foreign execution noise: mount/persistence/wiring/T0/core-smoke tiers
  queue no prompts, yet caught a prior tier's async execution error
  (PromptExecutionError, a 400 on /api/prompt). isForeignExecutionNoise
  filters execution-domain console lines from the non-executing tiers
  only; the executing tiers still assert them. Same "not this test's
  evidence" principle as event attribution (ARCHITECTURE section 9).
- Queue contention: the auto-run queue-busy guard hard-failed when a prior
  pack's slow CPU execution was still draining. drainUntilIdle waits it
  out (interrupt + clear + poll, throw-on-error so a failed read counts as
  busy); only a genuinely wedged backend fails. runBatch's post-timeout
  drain grows from 5s to 90s for the same reason.
- Slow-under-load misread as a regression: the single-node disambiguation
  re-run gets 60s instead of the batch's 20s. A real hang still exceeds it.

Also excludes the CLIPSeg model loaders (essentials, WAS) - model-download
nodes, same non-interruptible class as the listed BLIP/SAM/MiDaS loaders.
Reviewed by four-hat CORE (ship it); the new predicate is unit-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:22:11 -07:00
Nathaniel Parson Koroso
10d7769ab5 docs(custom-nodes): neutral phrasing in Detection Proof (drop first-person reference)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:32:41 -07:00
Nathaniel Parson Koroso
349d82b63e docs(custom-nodes): add Detection Proof plan (falsify every guard, correlate to real regressions)
The correlation matrix + throwaway-PR plan that proves the suite catches
every failure mode ARCHITECTURE.md claims: one deliberate break per
surface, each citing the real historical regression it recreates (Linear
Custom Node Bugs issues + FE PR #12279) and the exact CI red it produces.
Every "exact red" is captured from a real falsification run, not a
prediction. Renames the earlier "kill-test" work to the falsification
pass. States the honest local-full-run idempotency caveat (CI shards
per-pack; a single-backend serial run is not the oracle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:32:00 -07:00
Nathaniel Parson Koroso
6be7c18bef test(custom-nodes): sharpen three detection surfaces the break-suite exercise found
- Wiring drop resolution: the curated drag test only targeted first-slot
  inputs, so a slot hit-test regression that falls back to the first
  compatible input went undetected. Add a second-slot anchor
  (EmptyImage.IMAGE -> ImageBatch.image2) that only links if the drop
  resolves the exact slot; proven by breaking getNodeInputOnPos.
- Curated-run failure naming: a backend validation rejection answers
  /prompt with node_errors but app.queuePrompt swallows it, so a
  VALIDATION_FAIL reported {}. Capture and flatten the node_errors
  (summarizePromptError, typed off apiSchema PromptResponse) into the
  result's clientError and surface it in the T1 message, so a red names
  the node and input. Exported with a pure unit test since the happy path
  never runs it.
- Console-error window: document (README + ARCHITECTURE section 10) that
  the ledger collects per-tier, so boot-time pack console noise before the
  first tier action is out of scope by design, backstopped by the startup
  zero-visible-errors check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:27:07 -07:00
Nathaniel Parson Koroso
58e0fe7511 docs(custom-nodes): section 6 shows the census feeding three parsers; onboarding mirrors the scoped invariant
Round-3 review follow-ups: the definition-pipeline diagram no longer
implies a centralized normalizer (live census -> wiring slot normalizer /
execution classifier / mount declared-shape parser, matching section 4),
and ADDING_CUSTOM_NODES scopes the zero-visible-errors claim to the tiers
that assert it, same wording as the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:22:05 -07:00
Nathaniel Parson Koroso
72ad08db63 test(custom-nodes): shared console ledger, drift guards, and a doc truth pass from round-2 review
- Extract the pack console-error allowlist into a shared fixture
  (consoleErrorLedger.ts); the curated T1 run now collects console and
  page errors across load+run and asserts them through the ledger. The
  filter is pinned by a discriminating pure spec (pattern match,
  cross-pack ownership, unknown-pack fail-open).
- T1 asserts every expectedNodes type is actually present in the curated
  workflow before running it, killing the vacuous-green path where a
  drifted fixture shrank the executed-set check to an empty id list.
- typePairing records unrecognizable slot specs (unknownSlots on the
  node, unknownShapes on the plan) instead of silently dropping them;
  connectivity logs the list; pure tests pin the input/output drop paths
  and the socketless boundary.
- Add test:custom-nodes:ci, the gate-equivalent run against the
  backend-served built frontend; README re-scopes test:custom-nodes as
  the dev-server loop that is NOT the gate, and scopes the
  zero-visible-errors invariant to the tiers that hold it.
- ARCHITECTURE truth pass: event attribution leads with the positive
  prompt-id capture; section 10 grades ledger guards in three strengths;
  the decentralized parser story (declaredShape, classifyInput,
  normalizer) is stated consistently in section 4, gotcha G5, and the
  legend; ADDING_CUSTOM_NODES points the console ledger at its new home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:43:42 -07:00
Nathaniel Parson Koroso
24659c2caf test(custom-nodes): implement 8 external-review hardenings with discriminating self-checks
- positive prompt-id attribution: capture the /prompt response id as the
  primary event filter (seen-set + graph membership stay as depth); a new
  attribution self-check injects a foreign-prompt terminal error mid-run
  and proves it cannot fail the run
- console collection now includes pageerror (uncaught exceptions and
  rejections), with a collector self-check as positive control; surfaces a
  real Custom-Scripts betterCombos typeof-null bug, ledgered with mechanism
- connectivity allowlists are two-way stale-guarded: every entry must be
  observed failing in its recorded way, all stale keys reported per run
- manifest pins are required full 40-hex SHAs (CUSTOM_NODES_ALLOW_UNPINNED=1
  admits only empty pins, reserved for the planned pack-HEAD canary); pack
  must be a plain path segment; contract pinned by pure specs
- CI installs each pack under custom_nodes/<pack> with charset and pin
  gates before cloning (attribution keys on the install dirname)
- allNodes renderer loops honor rendererPassesFor (vueNodesCompatible)
- curated T1 asserts every display sink emitted a ui payload; console
  sinks documented as excluded (no ui payload by design)
- the always()-wrapper suggestion was rejected on sibling evidence:
  ci-tests-unit.yaml gates its required check with a changes job and
  job-level if, and no repo workflow uses a wrapper

Reviewed via ninja pipeline: 4-hat CORE panel (2 passes), senior QA gate
(2 rounds, discrimination proven by falsification), gated review (Primary,
Double Checker, Ultimate Skeptic - 15-entry evidence ledger, all PROVEN).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:34:08 -07:00
Nathaniel Parson Koroso
e46487f3ed docs(custom-nodes): regression-suite title, tier gating truth, verified stack claims
Implements the cold insider review's full finding set plus pipeline
verification fixes:
- title gains 'regression', matching every sibling doc
- wiring tier renderer coverage told truthfully (breadth sweep one,
  curated drags both); decision 7 enumerates all renderer surfaces
- section 5 gains the manifest tiers vocabulary bridge (run and
  connectivity gate; load and io currently gate nothing)
- renderers named once (LiteGraph / Vue Nodes 2.0); opener deduplicated
  against section 1; implementation map matches the real manifest schema
- section 13 names Playwright, bundled Chromium, GitHub Actions, with a
  caveated runtime ballpark; gotcha receipts carry only verifiable claims
- sections 10 and 13 diagrams conform to the doc's diagram grammar
- ADDING_CUSTOM_NODES tiers gloss and manifest.ts workflow comment
  aligned with the same gating reality

Reviewed via ninja pipeline: 4-hat CORE panel (2 passes), senior QA gate
(2 rounds), gated review (Primary, Double Checker, Ultimate Skeptic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:40:13 -07:00
Nathaniel Parson Koroso
3bc40bb148 docs(custom-nodes): legend reads in ascending section order per depth; name the map's ordering principle
Dagre's crossing minimizer ignores edge declaration order, so the fix is
node declaration order. Also states explicitly that the map is ordered by
zoom, not page order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:30:14 -07:00
Nathaniel Parson Koroso
cbceaf5dd9 docs(custom-nodes): context + execution + persistence diagrams restructured per review
- context diagram flows one way: driver -> frontend -> verdict synthesis -> team
- execution flow: classification fans out to its three verdicts; runnable paths
  converge on batching, blocked routes straight to reconciliation
- persistence check: sequence diagram replaced with a linear pipeline (one
  actor issuing commands is a procedure, not a message exchange)
- building blocks: tiers fan 2x2 inside the horizontal pipeline
- tripwire step + small recovers? diamond instead of one giant diamond

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:31:15 -07:00
Nathaniel Parson Koroso
497dd6ede5 docs(custom-nodes): building-blocks view horizontal, raise label wrap width
The building-blocks pipeline rendered as a tall narrow strip for two
mermaid reasons: labels auto-wrap at the ~200px default regardless of
line length, and a subgraph's declared direction is ignored once it has
external edges, so the tier row silently stacked vertically. The view
is now a left-to-right pipeline with the tier group in the middle, and
the wrap-width directive makes boxes wide instead of tall here and in
the definition-pipeline and execution-flow views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:15:08 -07:00
Nathaniel Parson Koroso
cec09da789 docs(custom-nodes): close the observation loop in the context view
The frontend box was a dead end: the suite drove it but nothing flowed
back, so the verdicts arrow to the team looked sourceless. Added the
return edge (observations back: what mounted, what persisted, what
executed, every error) and reworded the team edge so verdicts are
visibly the synthesis of those observations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:13:16 -07:00
Nathaniel Parson Koroso
9eb035e8a8 docs(custom-nodes): drop the redundant D-diagram labels; de-jargon the batching box
Headings were double-numbered ("2. D1 - system context") with an
internal diagram-numbering scheme that means nothing to a reader.
Sections are already numbered: headings now just name the view, and
every cross-reference points at a section. Also replaced "queue cost
is amortized" with plain English: one submission carries many nodes
instead of paying the round-trip per node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:08:53 -07:00
Nathaniel Parson Koroso
3cef258bac docs(custom-nodes): wide diagram boxes - strip forced line breaks from D1-D4
Reader feedback on the rendered views: manual line breaks inside boxes
force Mermaid to render narrow, tall boxes with heavy wrapping, so the
diagrams cost too much scrolling. Mermaid sizes a box to its longest
line, so the fix is one or two long lines per box with elaboration in
the prose below the diagram. Applied to D1 (context), D2 (pipeline),
D3 (definition pipeline), and D4 (execution flow); D7 and D8 stay as
approved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:06:01 -07:00
Nathaniel Parson Koroso
34faaa1a1d docs(custom-nodes): redraw D2/D3 for single-flow readability
Reader feedback on the rendered views: D2's service-to-tier arrows
crossed the whole diagram with ambiguous fan-ins (three unattributed
"pass/fail + exceptions" curves), and D3's corpus box mixed the
two-dialects annotation into a flow node right where three arrows fan
out, reading as if the dialects explained the fan-out.

Fixes, using the rules that make the CI view work: one direction of
flow per diagram, no many-to-many edges (the service-to-tier matrix is
now a table, which is what a matrix is), and annotations live in prose
rather than inside flow boxes. D2 is now a straight
manifest -> orchestrator -> tiers -> evidence -> verdict pipeline with
a three-row shared-services table; D3 moves the dialect fact into the
normalize step, labels the fan-out "derives", and adds one sentence
mapping each derived plan to its consuming tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:03:02 -07:00
Nathaniel Parson Koroso
95ced4c6ef docs(custom-nodes): ARCHITECTURE.md - C4-style views, design decisions, gotchas reference
Architecture documentation for the custom-node regression suite,
written as design views rather than an implementation dump:

- eight responsibility-level views: system context, building blocks,
  the node-definition pipeline, the execution flow, the persistence
  check, event attribution, the evidence model, and the CI deployment
  view; every diagram box names a responsibility or concept, arrows
  carry meaning, and decision points read in plain English
- a one-minute What/Why/How opening with the three explicit non-goals
  (output semantics, frontend-virtual nodes, hour-scale soak) and a
  clearly labeled scale snapshot so instance numbers never read as
  properties of the design
- a 12-row design-decisions table with honest trade-offs (why a real
  browser at all, why the backend serves the built frontend, one
  worker, disabled execution cache, pinned pack versions, one-row
  extensibility, per-tier renderer policy, mechanism-carrying
  exceptions, the two-way baseline, batch+bisect, and the scope line),
  plus the curated-workflow fixture named as the deliberate extension
  seam
- a 14-item gotchas reference, each entry in symptom / root cause /
  defense / which-team-concern-it-answers form, with named nodes kept
  only as worked examples of their class
- one implementation map section where architecture names meet code
  symbols, covering every building block including the orchestrator
  and the evidence ledgers
- the workflow's rotted sharding comment fixed (suite duration and
  the real shard trigger)

Grounded on the C4 model's published guidance, reviewed by an
independent architect pass (two view-coherence gaps found and fixed)
after three earlier Opus review passes on content accuracy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:08 -07:00
Nathaniel Parson Koroso
f9a94d0296 docs(custom-nodes): ARCHITECTURE.md - system design, data flow, and the incident behind every invariant
The suite had run/onboarding docs but nothing describing the SYSTEM:
what the pieces are, how node definitions flow through the planners and
classifiers, how the execution harness attributes outcomes, and why each
non-obvious rule exists. This adds the missing third doc with four
Mermaid box-line diagrams (system overview, def data flow, run pipeline,
CI pipeline), the tier-by-renderer coverage matrix, the full ledger
table with the two-way baseline semantics, and the hard-won invariants
each tied to the incident that forced it (widgetValueStore id bleed,
event cross-attribution, pack JS queue-hook crashes, Vue effect timing,
queue-jam tripwire). Scope contract is stated up front: compatibility
and regression gate, not a behavior certifier.

Every path, symbol, and number cross-checked against the tree before
commit. README and ADDING_CUSTOM_NODES now cross-reference it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:38:37 -07:00
Nathaniel Parson Koroso
e7681be896 test(custom-nodes): shape-census audit - classify V2-form combos, forceInput beats every form, census-derived fixtures
Systematic audit for siblings of the two combo bug classes (ungrounded
contract, shape blindness), driven by a live shape census of the exact
getNodeDefs object the suite consumes:

- classifyInput now handles the V2 schema form (string 'COMBO' with
  options in the opts object; 495 such inputs exist in the transformed
  defs): options present = widget, empty or remote/lazy = NEEDS_MODELS.
  Real effect measured: 8 KJNodes nodes were silently misclassified
  NEEDS_WIRES and never executed - 5 now run clean, 3 correctly land in
  NEEDS_MODELS (remote combos).
- forceInput now beats every input form, list-form combos included (a
  census-found form the old branch order classified as widget; today's
  4 instances are optional or non-manifest, so this is protection, not
  a behavior change).
- pure-spec fixtures for both parsers now include every census form,
  copied from real census examples (V2 options, V2 empty, V2 remote,
  forceInput-on-combo, cross-form vocabulary pairing) so fixtures can
  no longer self-confirm the parser's assumptions.
- ADDING_CUSTOM_NODES.md gains the evidence rules: independent-oracle
  grounding for semantic claims, shape-census-driven parsing with
  exclude-with-record on unknown shapes, and verify-against-the-source-
  the-code-consumes.

defaultInput checked against frontend source: deprecated and ignored
(nodeDefStore warning only) - deliberately not handled.

Local verification: full customNodes suite 72/72 under CI parity; lint,
format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:30:51 -07:00
Nathaniel Parson Koroso
70233bbd04 test(custom-nodes): combo vocabulary is a set, not a sequence; never pair combos with unknown options
A wired combo input bypasses its own widget, so menu order and the
options[0] default are not part of the wire contract - membership is
(backend validation checks value-in-options). Vocabulary fingerprints
are now order-insensitive (sorted, element-wise canonicalized). In the
current corpus this changes zero pairs (measured: no same-set,
different-order combos exist across the 7 packs); the rule is now
correct for packs where they do.

Auditing that change surfaced a real hole: the frontend's transformed
defs present some combos as the literal string COMBO with options in
the opts object. The old fingerprint hashed all of those identically,
silently cross-pairing dropdowns with no vocabulary evidence - exactly
the checkpoint-into-scheduler class the combo rule exists to exclude.
Normalization now pulls V2-form options, and a combo with no known
option list is excluded from pairing instead of blind-matched. Plan
moves 5,058 -> 5,030 pairs; the 28 removed were vocabulary-blind.

Also from CI: MiDaS Mask Image excluded (torch.hub download inside
execute hung the Linux runner; runs clean only where the hub cache is
warm) and ImageTransformKJ ledgered (pack JS initializes its
fill-options JSON widget on configure).

Local verification: full customNodes suite 68/68 twice, lint, format,
knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:01:32 -07:00
Nathaniel Parson Koroso
a22acc4f48 test(custom-nodes): save/reload under both renderers; classify client-side queue throws; scope decisions written in-code
Coverage-gap audit after the mount-fidelity miss (assertions silently
narrower than their claim). Fixes:

- save/reload now runs under BOTH renderers with staged evaluates and
  frame yields, so Vue component mount/configure effects actually flush
  before each serialize - the one renderer-dependent value path a
  LiteGraph-only pass could not see. Console errors are now collected
  during the tier too (configure-time pack JS noise was uncovered).
- queuePrompt is wrapped in-page: pack JS that THROWS mid-graphToPrompt
  (VHS applyToGraph crashed CI's whole VHS tier) now classifies as
  VALIDATION_FAIL carrying the exception text, so the offender
  self-identifies instead of aborting the tier. VHS_SelectLatest
  excluded with that mechanism: its applyToGraph assumes downstream
  inputs have widgets and hard-crashes when its output feeds a pure
  socket while the input dir has matching files (upstream-report
  candidate).
- pack-owned-value nodes (ROUNDTRIP_VALUE_ALLOWLIST) no longer receive
  set-and-stick probe writes - writing `_cn` markers into editor JSON
  widgets just made pack JS choke on our own probes.
- deliberate scopes are now stated where the assertion lives: auto-run
  runs single-renderer because execution is a backend contract and
  values flow through the same store in both renderers; it deliberately
  skips the zero-visible-errors check because it provokes expected
  failures; the connectivity breadth sweep is renderer-independent with
  the curated drag test covering both renderers; combo vocabulary
  matching is deliberately order-sensitive (option order defines the
  default).

Local verification: full customNodes suite 67/67 under CI parity, plus
lint, format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:31:43 -07:00
Nathaniel Parson Koroso
e6ed6120a1 test(custom-nodes): assert def-vs-instance mount fidelity under both renderers; reconcile Linux CI flips
Mount fidelity now has a renderer-independent bar: under BOTH the
LiteGraph and Vue passes, every created instance must materialize
everything its def declares - each non-socketless input exists as a
widget or a socket (autogrow templates count via their dot-qualified
expansion slots, e.g. variables.a/variables.b), and every declared
output exists. The Vue pass keeps its extra layer: the DOM must render
at least the instance's widget and slot counts. Verified against all
823 nodes under both renderers; the only def-shape special case found
was the core autogrow container semantics.

Also reconciles the first Linux CI run of the chain-builder tier:
- environment flips move to AUTO_RUN_EXCLUDE with mechanisms and leave
  the baseline: Image Analyze, Text Parse A1111 Embeddings (fail macOS,
  clean Linux), Image Crop Face (clean macOS, AttributeError Linux),
  ImageReceiver (av decode error macOS, clean Linux)
- run-to-run flip-floppers excluded: ImpactRemoteInt,
  ImpactSchedulerAdapter, ImpactQueueTriggerCountdown (queue-hook JS
  transient refusals), LoadText|pysssss (state-dependent file combo)

Local verification: allNodes 21/21 twice consecutively, plus lint,
format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:00:17 -07:00
Nathaniel Parson Koroso
5ce414653e test(custom-nodes): chain-run NEEDS_WIRES nodes, assert outputs, mount fidelity, widget round-trip, combo pairing
Executes ~340 more nodes and hardens every tier's assertions:

- CHAINABLE verdict: required sockets with a model-free producer
  (EmptyImage, EmptyLatentImage, SolidMask, Primitive*, EmptyAudio) are
  synthesized and wired automatically; NEEDS_WIRES now means only truly
  unproducible types (MODEL, SEGS, CONDITIONING...)
- auto-run asserts data flow: every PreviewAny sink must emit a ui
  payload (NO_OUTPUT class); OUTPUT_NODE targets stay event-covered
- Vue mount asserts DOM widget/slot counts (missing fails, extras and
  in-row control_after_generate tolerated)
- save/reload is now two passes: pristine (reload must never shrink a
  node or change a value - the "widgets disappear" bug class) and
  set-and-stick (every plain widget holds a programmatic non-default
  write and it survives reload where topology is stable)
- connectivity pairs COMBO slots on exact option-vocabulary match
  (+~120 pairs); mismatched vocabularies stay excluded by design
- harness invariants: node ids never reused within a page (the
  widgetValueStore keys state by node id and survives graph.clear(), so
  a reused id inherits stale widget values - core bug, reported
  separately), and run events are filtered by prompt id + graph node id
  membership so late websocket events or flap-retry double-queues can
  never pin one node's failure on the next
- new mechanism ledgers: WIDGET_SET_ALLOWLIST, ROUNDTRIP_VALUE_ALLOWLIST,
  MOUNT_WIDGET_ALLOWLIST, all stale-guarded; AUTO_RUN_EXCLUDE gains the
  observed offenders (rembg pip-install-at-execute, empty-find infinite
  loop, from_pretrained downloads, minutes-long per-pixel loops)
- manifest baselines reconciled against three observation runs; stale
  entries removed, real failures (missing optional deps, degenerate
  synthesized inputs, CUDA-only recorders) baselined

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:32:16 -07:00
Nathaniel Parson Koroso
c5de8d421d test: fix ImageGrabPIL pack attribution; exclude WAS ffmpeg path node
ImageGrabPIL is a KJNodes node and was ledgered under WAS, so its
exclusion never applied - moved to the right pack, and the auto-run
test now asserts every exclusion key is actually registered by its
pack so a wrong-pack entry fails loudly instead of silently doing
nothing. WAS Create Video from Path joins the exclusions with CI
evidence (ffmpeg discovery differs per host).
2026-07-07 10:43:57 -07:00
Nathaniel Parson Koroso
9bb0587ec5 Merge remote-tracking branch 'origin/main' into nathaniel/custom-node-e2e-suite 2026-07-07 10:24:36 -07:00
Nathaniel Parson Koroso
de23856742 test: exclude runtime model-downloaders and content-variable nodes from auto-run
The rebuilt environment surfaced the rest of the download class: WAS
BLIP/SAM/MiDaS model loaders all hang the queue in non-interruptible
weight downloads (and would mass-download on a networked runner), the
random.org node needs internet by definition, KJ LoadAndResizeImage and
WAS Create Grid Image follow input-dir contents, and Impact media
widgets preview values via root-relative URLs (console allowlist
widened). Every entry carries its mechanism; all keep mount,
save/reload, and connectivity coverage. 65/65 both environments.
2026-07-07 10:15:11 -07:00
Nathaniel Parson Koroso
650abec3ab test: fix browser typecheck errors; recalibrate exclusions for CI environment
typecheck:browser (which the lint job runs, unlike root typecheck)
caught an unused classifyInput param, a branded-NodeId lookup, and
api.interrupt's required argument. The first serialized CI run then
exposed environment-variable nodes: clean on one host, failing on the
other (screen capture with no X display, PIL screen grabs headless,
torch-stack RuntimeErrors that are macOS-only, state-dependent WAS
history). Those move from the cannotRunAlone baseline to
AUTO_RUN_EXCLUDE with per-node mechanisms so both environments stay
deterministic, and Impact's hardcoded example.png preview 404 joins
the scoped console allowlist.
2026-07-07 09:59:24 -07:00
Nathaniel Parson Koroso
b99100d0b4 test: un-export AutoRunClass (knip: consumers use AutoRunVerdict) 2026-07-06 17:12:24 -07:00
Nathaniel Parson Koroso
6d5bcb9e04 test: suppress deferred-await false positives in turnstile timer tests
vitest/valid-expect flags assertions stored before advancing fake
timers and awaited after - awaiting at creation would deadlock the
timer advance. The file is byte-identical to main; the warnings appear
because the type-aware lint toolchain moved. Suppressed per line with
the reason; also drops a scratch await added while chasing this.
2026-07-06 17:05:39 -07:00
Nathaniel Parson Koroso
0723702791 test: serialize CI workers, prune comment noise, clearer naming
The auto-run tier needs exclusive backend-queue access, so the CI job
now runs with workers=1 - parallel workers were interrupting each
other's executions and cross-attributing errors. Await the async
toHaveLength assertion the type-aware lint flagged, drop the
calibration measurement harness (one-shot scaffolding; the measured
constant keeps a provenance note), rename NO_SINK to
NO_OBSERVABLE_OUTPUT, and cut comments down to load-bearing WHYs.
2026-07-06 17:03:21 -07:00
Nathaniel Parson Koroso
9825047176 ci: pin every manifest pack to its verified commit
An unpinned pack means any upstream push can red the gating check for
every PR in the repo. Each row now pins the exact SHA the suite was
verified against locally (all tiers green, both environments); bumps
are deliberate, re-verified changes. Also records why the job is not
sharded yet: per-shard setup (~4.5 min of pack installs and backend
boot) dominates the ~5.5 min suite, so a prebuilt image comes first.
2026-07-06 16:33:53 -07:00
Nathaniel Parson Koroso
7adfaa9079 test: stabilize every-node auto-run across environments; document the tiers
The cannotRunAlone baseline (per pack, in the manifest) records nodes
that cannot execute standalone on a bare backend, asserted both ways so
entries cannot rot: an unlisted failure is a regression, a listed node
that runs clean must be removed. queuePrompt rejection is retried once
before classifying VALIDATION_FAIL - pack JS hooking the queue path can
refuse transiently, and the backend log proved several apparent rejects
never reached the server. Nodes whose execution depends on their own
pack JS preprocessing widget values (rgthree Power widgets, KJ editors)
are excluded unconditionally with the mechanism recorded, since whether
a page applies pack JS varies by serving setup; ML-session initializers
and unstable executed-set reporters join them. ADDING_PACKS and the
README document the every-node tiers and all five exception ledgers.

Verified 65/65 in both documented environments: dev server and
dist-serving CI parity, twice consecutively on the latter.
2026-07-06 16:16:51 -07:00
Nathaniel Parson Koroso
1e36107109 test: every-node coverage - mount, save/reload, connect, and auto-run for all pack nodes
All-nodes tiers discover each pack's full node list from the live
backend: chunked mount checks in both renderers (batch size 24, chosen
by the committed calibration tool), chunked save/reload round-trips,
a connectivity corpus widened from the curated sentinels to every
registered node, and an auto-run tier that classifies every node
(AUTO_RUNNABLE / NEEDS_WIRES / NEEDS_MODELS / NO_SINK) and executes the
runnable ones in batches with per-node bisection on failure.

Hard-won harness rules baked in: queuePrompt rejects classify instantly
as VALIDATION_FAIL instead of burning the timeout; a timed-out batch
interrupts and verifies the queue drained so one hung node cannot jam
every later run; a pre-flight queue check fails fast with the real
cause; and three reviewable exception ledgers carry reasons inline
(AUTO_RUN_EXCLUDE for runtime-downloaders like RemBGSession+, a scoped
console-noise allowlist for KJNodes' undefined-filename previews,
connectivity CONNECT_REJECTED/ROUNDTRIP_LOST entries for pack JS that
vetoes or drops links).
2026-07-06 16:16:23 -07:00
Nathaniel Parson Koroso
1d5514c90e docs: rename ADDING_PACKS to ADDING_CUSTOM_NODES
The doc onboards custom nodes; name it what it is.
2026-07-06 16:16:09 -07:00
Nathaniel Parson Koroso
61d1cbfdb0 docs: fix ADDING_PACKS extensions probe and misattributed CI triage
grep -c on the single-line /extensions JSON could only say 0 or 1; count
entries properly with the same python one-liner style the doc already
uses. The Step 7 triage claimed our CI failure was upstream drift; it was
the dev-server blindspot - reorder the advice to reproduce under 6b
before diagnosing.
2026-07-02 18:49:20 -07:00
Nathaniel Parson Koroso
14666b09c4 docs: fold 5-pack onboarding lessons into ADDING_PACKS
Detect frontend-JS packs at install time, split local verification into
the fast dev-server loop and the CI-parity dist run (required when the
pack ships frontend JS), spell out that workflow media paths resolve
against the backend's working directory, and add upstream-drift triage
for unpinned packs. Checklist updated to match.
2026-07-02 18:46:05 -07:00
Nathaniel Parson Koroso
efb0365bc3 test: make connectivity instance-aware; slot drags survive pack page chrome
Two CI failures with the 7-pack backend, both from pack frontend JS that
never loads under the Vite dev server (its /extensions list is core-only):

- rgthree's Seed rebuilds its declared seed input as a widget-only
  control, so the planned BatchCount+.INT -> Seed.seed pair has no socket
  on the instance. The sweep now classifies that as
  WIDGET_ONLY_ON_INSTANCE, logged and excluded like wildcards; a name
  missing from both slots and widgets still fails hard, and the drag test
  picks the first in-pack pair that materializes on real instances.

- rgthree's progress bar shifts the canvas element 16px down, and
  NodeSlotReference.getPosition returned canvas-relative coordinates, so
  every slot drag grabbed the node title instead of the slot dot. Slot
  positions now include the canvas element's page offset (a no-op when
  the canvas sits at 0,0).

Documents the dev-server blindspot and the CI-parity loop (build dist,
--front-end-root) in the suite README and ADDING_PACKS. Verified 36/36
green against both the dev server and a dist-serving 7-pack backend.
2026-07-02 18:42:48 -07:00
Nathaniel Parson Koroso
065bc0c336 test: fail manifest load when a run tier has no workflow
A run row with an empty workflow would skip locally and rely on CI's
skip gate to notice the lost coverage; enforce the documented contract
at load time instead.
2026-07-02 18:13:26 -07:00
Nathaniel Parson Koroso
1248c4628a test: onboard 5 packs (rgthree, essentials, KJNodes, Custom-Scripts, WAS) with vueNodesCompatible flag and ADDING_PACKS guide
Five new manifest rows, each covering load, connectivity, and run tiers
with hand-authored model-free workflows verified against a live backend.
New optional vueNodesCompatible manifest field: a pack proven unable to
mount under Vue Nodes 2.0 runs its LiteGraph assertions only - never a
test.skip, so the zero-skip CI gate stays honest. All five packs mount
under Vue Nodes 2.0 empirically, so no row sets the flag; the decision
helper is unit-tested instead. ADDING_PACKS.md is the authoritative
step-by-step onboarding process, validated against live /object_info.
Manifest rows now also fail fast on an empty repo field.
2026-07-02 18:05:20 -07:00
Nathaniel Parson Koroso
ee83d67834 test: single tier source of truth; fix skip diagnostic to find nested skips
Derive CustomNodeTier from the VALID_TIERS array (as const) so adding a tier
is one edit and the type/runtime lists can't drift. The forbid-skips
diagnostic now recurses the report and prints only specs that actually
skipped - the old dump printed every title and a single-level filter would
miss specs nested under describe() blocks (which the regression spec uses).
2026-07-02 16:07:04 -07:00
Nathaniel Parson Koroso
f63b7d866e ci: gate custom-node job with changes-filter, not trigger paths
A required check gated by a trigger-level paths filter never creates a check
run on a PR that touches none of those paths, leaving branch protection stuck
Pending. Move the gating to a job-level if via the changes-filter action (a
skipped job counts as passing), mirroring ci-tests-unit.yaml, so this can be
marked required without stalling docs-only PRs. Keeps the same-repo fork guard
in the same if.
2026-07-02 16:03:37 -07:00
Nathaniel Parson Koroso
068191ea47 test: harden custom-node CI and manifest per review
Security: the pack-install job now runs only for same-repo PRs and pushes, so
a fork PR can't point the manifest's repo URLs at attacker-controlled code
that the job would clone and pip-install. Fork PRs keep the env-agnostic
coverage via the main e2e shards.

Stability: pack requirements install under a pip constraint pinning the CPU
torch stack, so no pack can swap torch for a GPU/incompatible build on the
--cpu runner.

Correctness: manifest validation rejects unknown tier values (a 'connectivty'
typo would otherwise silently drop that tier's coverage). Connectivity's
'pack installed' predicate is extracted to one isEntryInstalled helper used by
both the breadth and drag tests.
2026-07-02 15:56:17 -07:00
Nathaniel Parson Koroso
07c4b230b2 ci: make the custom-node job gating - fail on pack-install error or any skip
A regression gate that lets a broken pack through as a skip is theater. Pack
clone/dependency failures now fail the job (array+loop instead of a
failure-swallowing jq|while pipe), and a post-run check fails the job if any
test was skipped - on this backend every tier is meant to run, so a skip
means a pack or devtools did not load. Drops the informational framing;
mark custom-nodes-e2e required in branch protection to block merges.
2026-07-02 15:36:19 -07:00
Nathaniel Parson Koroso
9ed51f1e4b ci: run the custom-node suite against a backend with the packs installed
Phase 5. A new informational (non-gating) workflow that reuses the repo's
setup-frontend/setup-playwright/setup-comfyui-server actions, then installs
every pack the manifest declares (jq loop over customNodeManifest.json, so a
new pack row installs itself with no workflow change) and boots ComfyUI with
--multi-user --cache-none before running browser_tests/tests/customNodes.

This makes the load and run tiers actually execute in CI instead of skipping
for want of the packs - the whole point of the suite. A pack whose deps fail
degrades to an honest skip rather than reddening the job.
2026-07-02 15:25:31 -07:00
Nathaniel Parson Koroso
4a91fa4849 test: name connectivity tests in plain language
T-conn was planning-doc shorthand for the connectivity tier; test titles and
logs now say connectivity outright so CI output reads without tribal
knowledge.
2026-07-02 14:11:56 -07:00
Nathaniel Parson Koroso
0991905a89 test: exclude COMBO literals from connectivity auto-pairing
CI caught what a pack-rich local backend masked: isValidConnection compares
only the string COMBO while every combo slot carries its own option set, so
the planner would wire a checkpoint dropdown into a scheduler dropdown and
call it proof, and combo outputs declare a non-string output_name whose
instance slot name never matches (DevToolsNodeWithOutputCombo failed 5
pairs on CI as SLOT_CONTRACT_MISMATCH). Combo slots are now recorded and
counted like wildcards instead of paired, the normalizer coerces slot names
to strings, and a pure spec locks both behaviors. Targeted fixtures remain
the way to cover combo semantics.
2026-07-02 14:10:37 -07:00
Nathaniel Parson Koroso
df6764762b test: make the custom-node suite work on multi-user backends
The Comfy.userId=default settings override broke every test on multi-user
backends (the repo's stated browser-test prerequisite): devtools
set_settings wrote to a user no session reads, so Comfy.TutorialCompleted
never landed, the templates dialog never opened, and the beforeEach wait
timed out - CI sessions even inherited leftover settings (a zh locale) from
earlier tests on the same worker user. Dropping the override lets the
fixture target the real per-worker user everywhere; the harness backend now
runs --multi-user like CI. Connectivity's per-pack guards and drag
derivation apply only to installed packs, so a backend without the manifest
packs reports the absence instead of hard-failing while the core sweep,
native drag, and self-checks still run.
2026-07-02 13:45:34 -07:00
Nathaniel Parson Koroso
2d2b318450 test: drop exports from internal-only custom-node types
knip flags exported types with no external consumers; CustomNodeTier,
ObjectInfoNode, NormalizedSlot, and SlotRef are referenced only within
their own modules.
2026-07-02 13:15:58 -07:00
GitHub Action
0f94da8746 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 19:32:18 +00:00
Nathaniel Parson Koroso
d80427d014 test: assert breadth-sweep console errors and tighten manifest shape checks
The breadth sweep now fails on any console error captured during the
connect/serialize/prompt loop, matching the fidelity test. The wildcard
predicate is exported from typePairing and reused instead of re-derived.
assertEntry validates real shapes (non-empty pack/expectedNodes/tiers,
arrays, boolean requiresGpu, finite positive timeoutMs); workflow stays
allowed as an empty string until a pack gains a run-tier fixture.
2026-07-02 12:28:23 -07:00
Nathaniel Parson Koroso
d02e665290 test: address review feedback on the custom-node suite
Resolve the manifest path from import.meta.url so tests are cwd-independent,
and validate requiresGpu at manifest load. Reuse the centralized TestIds for
the error overlay, error dialog, and templates dialog selectors. Extract the
shared suite settings and templates-dialog dismissal into
fixtures/utils/customNodeSuite so the three specs cannot drift. Rename
spikeDesktop.spec.ts to coreSmoke.spec.ts to match its maintained purpose,
document the full manifest schema in the README, and describe the gate
outcome without a hardcoded test count.
2026-07-02 12:24:33 -07:00
Nathaniel Parson Koroso
dc83cc4df6 test: prove the connectivity executor can reject, and drag every pack
A permanent self-check feeds the shared pair executor a type-incompatible
pair and a fabricated slot name and requires CONNECT_REJECTED and
SLOT_CONTRACT_MISMATCH back, so a green sweep can never come from a
classifier that lost the ability to fail. The breadth test asserts every
connectivity-tier pack contributes pairs, guarding pack attribution. The
drag tier's widget-primitive exclusion is removed: widget-backed inputs
render real slot dots under Vue Nodes (verified empirically), so every pack
now gets an in-pack drag in both renderers, asserted present.
2026-07-02 12:19:40 -07:00
Nathaniel Parson Koroso
8b81a4f359 test: add connectivity tier proving the slot/type contract
A type-pairing generator indexes /object_info producers and consumers and
plans one representative typed edge per slot, excluding wildcard slots
(isValidConnection short-circuits on * before the real type compare, so a
wildcard link proves reachability, not interop). The breadth sweep connects
every planned edge through the real validator in-page and requires each link
to survive serialize/configure and appear in graphToPrompt output; verified
up front that graphToPrompt emits links even when other required inputs
dangle. A curated subset is dragged slot-dot to slot-dot under both
renderers, addressed by data-slot-key so shared labels cannot misfire.
Orphan types are reported, never failed; connect vetoes must match a
committed allow-list. Manifest packs opt in via a connectivity tier that
needs no extra assets.
2026-07-02 12:14:39 -07:00
GitHub Action
8f567e8ef0 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 18:44:36 +00:00
Nathaniel Parson Koroso
4fb282f853 docs: link custom-node suite README from browser_tests README 2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
d17a387ddb test: name each custom-node check as a pnpm script and document the suite
One script per pack tier (impact-render/impact-run/vhs-render/vhs-run) plus
the self-check, all opening the Playwright Inspector so anyone can step
through what the robot does. README covers prerequisites, every script, a
worked example, the zero-visible-errors contract, and how to add a pack.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
68ba0aa613 test: add pnpm scripts for the custom-node suite
test:custom-nodes runs the whole suite headless (the gate); :watch opens a
headed slow-motion run of the browser tiers; :debug steps through them in the
Playwright Inspector. All target the local dev server on :5173 and use the
committed system-Chrome config (no bundled-chromium download). Pass -g to
:watch / :debug to run a single test, e.g. -g 'VideoHelperSuite.*T1'.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
675140c164 test: drop io tier scaffold until assertion-node pack exists
T2a gated on the ComfyUI-test-framework 'Assert Executed' nodes, which are
not published for any backend yet, so the tier could only ever skip. A test
that cannot run anywhere is reporting noise; restore it from history when
the assertion pack lands. Suite is now 16 passed, zero skips, zero failures.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
64706c53c3 test: enforce zero visible errors across custom-node suite
Every browser tier now asserts the app's user-facing error surfaces (error
overlay, error dialog, node render errors, error toasts) are absent at test
start and after each pass, so a run is green only if a human watching the
screen sees zero errors. The harness self-check asserts the overlay IS
visible after a forced execution error, keeping the selectors provably live.

Sessions boot with a blank graph (Comfy.TutorialCompleted=false) because the
bundled default template references models absent on a scoped backend; the
tutorial path's auto-opened template browser is dismissed per test. Settings
now reach the session on single-user server-storage backends by routing
devtools set_settings to the default user, and the errors tab stays enabled
so error indicators are never suppressed in this suite. The smoke test loads
a core-only model-free workflow instead of the SD1.5 default asset.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
bfa94d4118 test: enable run tier for Impact and VHS with model-free workflows
Impact runs ImpactInt and ImpactFloat into PreviewAny as a group; VHS decodes
the existing plain_video.mp4 asset through VHS_LoadVideoPath into
VHS_VideoInfo. The executed-set check asserts each expected node individually
executed, so group workflows still verify per-node execution. Requires a
cache-disabled backend (--cache-none) with the video staged in its input dir;
documented on the manifest workflow field.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
b7708d5ad0 test: validate timeoutMs and requiresModels at manifest load 2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
564de12d46 test: harden custom-node suite per review findings
Normalize the executing event tap: its CustomEvent detail is a bare node-id
string, so the previous object-spread left the executed-set permanently empty.
The self-check now asserts a non-empty executed-set to keep that path live.

T0 now clears the graph per renderer pass, asserts exact node counts, and
verifies each added pack node's own data-node-id mounts in the Vue pass
(default-workflow nodes can no longer satisfy the assertions). Console capture
starts before the renderer toggle. Added an object_info sanity floor so a
depleted getNodeDefs fails loudly instead of skipping everything.

Run/io tiers gain test.setTimeout, requiresModels gating, and an empty-workflow
guard. Interrupted runs get pure-spec coverage; the shared console collector
moves to fixtures/utils.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
5a1f788230 test: custom-node E2E regression suite (load/render, both renderers)
Data-driven Playwright harness verifying custom-node packs load and render
under both LiteGraph 1.0 and Vue Nodes 2.0 against a real ComfyUI backend.
Pure classifier/validator/manifest logic is unit-tested; the regression spec
renders each pack's nodes in both renderers (Vue via data-node-id DOM) and a
self-check runs a workflow to confirm execution-error capture. Proven against
Impact Pack + VideoHelperSuite.

Makes ComfyPage.createUser idempotent so the suite runs against a persistent
backend (Desktop server user storage).
2026-07-02 11:39:53 -07:00
420 changed files with 9006 additions and 24363 deletions

View File

@@ -9,6 +9,10 @@ inputs:
description: 'Whether to launch the server after setup'
required: false
default: 'false'
comfyui_ref:
description: 'ComfyUI git ref to check out (tag/branch/SHA). Empty = default branch (master).'
required: false
default: ''
runs:
using: 'composite'
steps:
@@ -19,6 +23,7 @@ runs:
uses: actions/checkout@v6
with:
repository: 'comfyanonymous/ComfyUI'
ref: ${{ inputs.comfyui_ref }}
path: 'ComfyUI'
- name: Install ComfyUI_devtools from frontend repo

View File

@@ -0,0 +1,214 @@
# Runs the custom-node regression suite against a backend that has the manifest
# packs actually installed, so the load/run tiers execute for real. This is a
# GATING check: if a pack fails to install or any tier is skipped, the job goes
# red - a regression gate that let a broken pack through as a "skip" would be
# pointless. Mark `custom-nodes-e2e` as a required status check in branch
# protection to block merges on failure.
name: 'CI: Tests Custom Nodes'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
push:
branches: [main, master]
merge_group:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Path gating lives here, not in a trigger-level `paths:` filter: a required
# check gated by trigger paths never creates a check run on an unrelated PR
# and leaves branch protection stuck Pending. A job-level `if:` still creates
# the check and marks it Skipped (= passing). Mirrors ci-tests-unit.yaml.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Deliberately NOT sharded yet: the suite is ~8 min but every shard would
# pay the full ~4.5 min setup (clone + pip-install every pack + boot the
# backend), so 2 shards buy ~4 min of wall time for double the runner cost,
# with diminishing returns beyond that. Sharding pays once test time dwarfs
# setup time -
# first cut setup with a prebuilt image of the pinned packs, then shard if
# the job exceeds ~12 minutes.
custom-nodes-e2e:
needs: changes
# Run only when non-docs code changed AND the PR is same-repo. Fork PRs can
# edit the manifest's repo/pin URLs, and this job clones and pip-installs
# whatever they point at (setup.py runs at install time), so an untrusted
# fork must not be able to aim the clone at an attacker-controlled repo.
# Fork PRs still get the environment-agnostic coverage via the main e2e
# shards. A skipped job counts as passing, so this stays required-safe.
if: >-
needs.changes.outputs.should-run == 'true' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
# DETECTION PROOF (demo branch only): a green suite fits in 30, but with
# every surface deliberately broken the suite walks hundreds of failure
# paths (single re-runs, drains), so it needs far more headroom.
timeout-minutes: 90
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Checks out ComfyUI, installs Python/torch/requirements and ComfyUI_devtools.
# launch_server:false so we can add the manifest packs before booting.
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
# Install every pack the manifest declares (DRY: a new pack row installs
# itself here, no workflow change). A clone or dependency failure fails the
# job - if a pack can't be installed, its coverage can't run, and that is a
# gate failure, not something to paper over. The `jq | while` pipe hides
# failures in a subshell, so read into an array and loop with `set -e`.
- name: Install manifest custom nodes
shell: bash
run: |
set -euo pipefail
# Pin the CPU torch stack that setup-comfyui-server installed so no
# pack's requirements.txt can pull a GPU/incompatible torch onto this
# --cpu runner. A pack that genuinely needs a different torch fails
# the constrained install loudly rather than silently swapping it.
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' \
> /tmp/torch-constraints.txt || true
manifest=browser_tests/fixtures/data/customNodeManifest.json
mapfile -t entries < <(jq -c '.[]' "$manifest")
for entry in "${entries[@]}"; do
repo=$(jq -r '.repo' <<<"$entry")
pin=$(jq -r '.pin' <<<"$entry")
# Install under the manifest `pack` key, not basename(repo): node
# attribution keys on the install dirname via python_module, and
# the two only coincide by luck. Same charset the manifest loader
# enforces - belt for anything that bypasses it.
pack=$(jq -r '.pack' <<<"$entry")
if ! [[ "$pack" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "::error::unsafe pack name: '$pack'"; exit 1
fi
# The gate tests exactly what was verified: a full SHA pin is
# mandatory here, before anything installs. The planned canary
# (pack HEADs) is the only intended unpinned consumer and runs
# with CUSTOM_NODES_ALLOW_UNPINNED=1 through the loader instead.
if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::$pack: pin must be a full commit SHA (got '$pin')"; exit 1
fi
dir="ComfyUI/custom_nodes/$pack"
echo "::group::install $pack"
git clone --depth 1 "$repo" "$dir"
git -C "$dir" fetch --depth 1 origin "$pin"
git -C "$dir" checkout "$pin"
if [ -f "$dir/requirements.txt" ]; then
pip install -r "$dir/requirements.txt" -c /tmp/torch-constraints.txt
fi
echo "::endgroup::"
done
# DETECTION PROOF (rows 8-10): the pack-shipped bugs the suite is meant to
# catch live inside third-party pack repos, which CI clones fresh at their
# pins - a normal frontend commit cannot reach that code. This step, on the
# never-merge nathaniel/detection-proof branch ONLY, pokes one verified
# break into each cloned pack right after install, so the Pack-mode rows go
# red on real pack failures. Each break asserts it landed (grep) so a silent
# no-op cannot fake a pass. Do NOT port this step to any real suite branch.
- name: DETECTION PROOF - break packs (rows 8-10)
shell: bash
run: |
set -euo pipefail
# Row 8 (console/pageerror ledger, s10): Custom-Scripts showText.js
# logs a console.error when the node executes. onExecuted, not
# onConfigure: the wiring sweep configures nodes but queues no
# prompts, so an onConfigure error would red the sweep's console
# assert first and starve row 4's CONNECT_REJECTED signature.
# Expected: curated run (T1) red with `console errors during curated
# run` + the text + script URL.
f=ComfyUI/custom_nodes/ComfyUI-Custom-Scripts/web/js/showText.js
perl -pi -e "s/(onExecuted\?\.apply\(this, arguments\);)/\$1 console.error('DETECTION PROOF (row 8): pack showText.js onExecuted failure');/" "$f"
grep -q "DETECTION PROOF (row 8)" "$f" || { echo "::error::row 8 break did not apply"; exit 1; }
# Row 9 (execution runtime, s7): WAS `return_constant_number` raises
# on entry. Expected: auto-run tier red `Constant Number:
# EXECUTION_ERROR ... not in cannotRunAlone; a regression`.
f=ComfyUI/custom_nodes/was-node-suite-comfyui/WAS_Node_Suite.py
perl -pi -e 's/(def return_constant_number\(self, number_type, number, number_as_text=None\):)/$1\n raise ValueError("DETECTION PROOF (row 9): pack node runtime failure")/' "$f"
grep -q "DETECTION PROOF (row 9)" "$f" || { echo "::error::row 9 break did not apply"; exit 1; }
# Row 10 (registration sentinels, s5/s10): Impact renames the
# `ImpactInt` node-class key, so the node no longer registers under
# its expected class_type. Expected: the mount + curated tests that
# reference ImpactInt skip, and the "Forbid skipped tests" gate fails
# the job on `skipped != 0`.
f=ComfyUI/custom_nodes/ComfyUI-Impact-Pack/__init__.py
perl -pi -e 's/"ImpactInt": ImpactInt,/"ImpactIntDETECTIONPROOF": ImpactInt,/' "$f"
grep -q "ImpactIntDETECTIONPROOF" "$f" || { echo "::error::row 10 break did not apply"; exit 1; }
# The VHS run-tier workflow reads input/plain_video.mp4.
- name: Stage run-tier assets
shell: bash
run: cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4
# --cache-none so retried run-tier tests re-execute every node (a cached
# node emits no `executing` event and would false-fail PARTIAL).
- name: Start ComfyUI server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --cache-none --front-end-root ../dist &
wait-for-it --service 127.0.0.1:8188 -t 600
- name: Run custom-node suite
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: custom-nodes-results.json
run: |
# workers=1: the auto-run tier needs exclusive backend-queue access;
# parallel workers interrupt each other's executions.
pnpm exec playwright test browser_tests/tests/customNodes/ \
--project=chromium --reporter=list,json --workers=1
# A skip here means a pack or devtools did not load: on this backend every
# tier is meant to run, so a skip is a gate failure, not an honest pass.
- name: Forbid skipped tests
if: always()
shell: bash
run: |
set -euo pipefail
skipped=$(jq '.stats.skipped' custom-nodes-results.json)
echo "skipped tests: $skipped"
if [ "$skipped" != "0" ]; then
echo "::error::$skipped test(s) skipped - a manifest pack or devtools failed to load; skips are not acceptable in the gating job"
# Recurse so specs nested under describe() blocks are found, and
# print only the specs that actually skipped.
jq -r '.. | objects
| select(has("title") and has("tests"))
| select(any(.tests[]?; .status == "skipped"))
| .title' custom-nodes-results.json | sort -u | head -40
exit 1
fi
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report-custom-nodes
path: playwright-report/
retention-days: 7
if-no-files-found: warn

View File

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }}
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

View File

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

View File

@@ -1,51 +0,0 @@
import { expect } from '@playwright/test'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
import { test } from './fixtures/blockExternalMedia'
test.describe('Brand portal @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/brand')
})
test('renders each brand guideline section', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'One mark, many dimensions.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Every color earns its place.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Precise, never cute.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Trademark guidelines.' })
).toBeVisible()
})
test('shows all four marks', async ({ page }) => {
const logos = page.locator('#logos')
for (const name of [
'Core Logo',
'Logomark',
'Icon',
'Amplified Logomark'
]) {
await expect(logos.getByText(name, { exact: true })).toBeVisible()
}
})
test('the hero ctas open the gated guidelines and the logo bundle', async ({
page
}) => {
await expect(
page.getByRole('link', { name: 'View brand guidelines' })
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
await expect(
page.getByRole('link', { name: 'Download logos' })
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
})
})

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers - Comfy')
await expect(page).toHaveTitle('Careers Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,112 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
})
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
page
}) => {
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
await expect(installLinks).toHaveCount(2)
for (const link of await installLinks.all()) {
await expect(link).toHaveAttribute('href', '#setup')
}
})
test('setup section shows both install options', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
})
test('capabilities section shows all six tool cards', async ({ page }) => {
for (const title of [
'Generate anything',
'Search the ecosystem',
'Run real workflows',
'Direct any model',
'Generate in batches',
'Ship it as an app'
]) {
await expect(
page.getByRole('heading', { name: title, exact: true })
).toBeVisible()
}
})
test('FAQ lists nine questions and autolinks the server URL', async ({
page
}) => {
const triggers = page.locator('[id^="faq-trigger-"]')
await triggers.first().scrollIntoViewIfNeeded()
await expect(triggers).toHaveCount(9)
await page.getByRole('button', { name: "What's the server URL?" }).click()
await expect(
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
).toHaveAttribute('href', MCP_ENDPOINT)
})
})
test.describe('MCP page zh-CN @smoke', () => {
test('setup section renders localized options', async ({ page }) => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,4 +0,0 @@
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, reactive, watch } from 'vue'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
@@ -9,31 +9,6 @@ const { faqs } = defineProps<{
faqs: readonly Faq[]
}>()
type AnswerPart = { type: 'text' | 'link'; value: string }
function parseAnswer(answer: string): AnswerPart[] {
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
const parts: AnswerPart[] = []
let lastIndex = 0
for (const match of answer.matchAll(urlPattern)) {
const start = match.index ?? 0
const url = match[0].replace(/[.,;:]+$/, '')
if (start > lastIndex) {
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
}
parts.push({ type: 'link', value: url })
lastIndex = start + url.length
}
if (lastIndex < answer.length) {
parts.push({ type: 'text', value: answer.slice(lastIndex) })
}
return parts
}
const parsedFaqs = computed(() =>
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
)
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
@@ -65,7 +40,7 @@ function toggle(index: number) {
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in parsedFaqs"
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
@@ -108,23 +83,8 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
>
<template
v-for="(part, partIndex) in faq.answerParts"
:key="partIndex"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>
</div>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
@@ -20,7 +20,6 @@ type RowMedia =
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
export interface FeatureRow {
@@ -59,7 +58,7 @@ const {
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
@@ -73,11 +72,10 @@ const {
</div>
<!-- Media: image or video -->
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
<div
:class="
cn(
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
@@ -88,12 +86,7 @@ const {
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
:class="
cn(
'absolute inset-0 size-full rounded-4xl',
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
)
"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
@@ -106,13 +99,7 @@ const {
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
:fit="row.media.fit"
:class="
cn(
'absolute inset-0 size-full',
row.media.fit === 'contain' && 'bg-transparent'
)
"
class="w-full"
/>
</div>
</GlassCard>

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
links: [
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.brand', locale), href: routes.brand },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
@@ -176,7 +175,10 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
</div>
</footer>
</template>

View File

@@ -10,7 +10,6 @@ import {
whenever
} from '@vueuse/core'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import type { HTMLAttributes } from 'vue'
import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
@@ -31,9 +30,7 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false,
fit = 'cover',
class: className
hideControls = false
} = defineProps<{
locale?: Locale
src?: string
@@ -43,8 +40,6 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
class?: HTMLAttributes['class']
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -194,12 +189,7 @@ function toggleFullscreen() {
<template>
<div
ref="playerEl"
:class="
cn(
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
className
)
"
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
@pointermove="showControls"
@pointerdown="showControls"
@focusin="showControls"
@@ -207,9 +197,7 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
class="size-full object-cover"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

View File

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

View File

@@ -20,8 +20,6 @@ export const buttonVariants = cva(
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
inline:
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -21,8 +21,7 @@ const baseRoutes = {
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp',
brand: '/brand'
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -88,7 +87,6 @@ export const externalLinks = {
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpEndpoint: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',

View File

@@ -1,7 +1,5 @@
import type { LocalizedText } from '../i18n/translations'
import { BRAND_ASSETS_ZIP } from './brandAssets'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
@@ -9,6 +7,9 @@ interface AffiliateBrandAsset {
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',

View File

@@ -1,9 +0,0 @@
// Shared brand download URLs served from the media bucket, used by both the
// affiliate page and the brand portal.
export const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
export const BRAND_GUIDELINES_PDF =
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'

View File

@@ -1,91 +0,0 @@
interface BrandColor {
name: string
hex: string
rgb: string
hsl: string
cmyk: string
swatchClass: string
textClass: string
wide?: boolean
border?: boolean
}
export const brandColors: readonly BrandColor[] = [
{
name: 'Comfy Yellow',
hex: '#F2FF59',
rgb: '242, 255, 89',
hsl: '65, 100, 67',
cmyk: '5, 0, 65, 0',
swatchClass: 'bg-primary-comfy-yellow',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Comfy Ink',
hex: '#211927',
rgb: '33, 25, 39',
hsl: '274, 22, 13',
cmyk: '15, 36, 0, 85',
swatchClass: 'bg-primary-comfy-ink',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Comfy Canvas',
hex: '#C2BFB9',
rgb: '194, 191, 185',
hsl: '40, 7, 74',
cmyk: '0, 2, 5, 24',
swatchClass: 'bg-primary-comfy-canvas',
textClass: 'text-primary-comfy-ink'
},
{
name: 'Comfy Plum',
hex: '#49378B',
rgb: '73, 55, 139',
hsl: '253, 43, 38',
cmyk: '47, 60, 0, 45',
swatchClass: 'bg-primary-comfy-plum',
textClass: 'text-primary-comfy-canvas'
},
{
name: 'Warm White',
hex: '#F0EFED',
rgb: '240, 239, 237',
hsl: '40, 9, 94',
cmyk: '0, 0, 1, 6',
swatchClass: 'bg-primary-warm-white',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Warm Gray',
hex: '#7E7C78',
rgb: '126, 124, 120',
hsl: '40, 2, 48',
cmyk: '0, 2, 5, 51',
swatchClass: 'bg-primary-warm-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Cool Gray',
hex: '#3C3C3C',
rgb: '60, 60, 60',
hsl: '0, 0, 24',
cmyk: '0, 0, 0, 76',
swatchClass: 'bg-secondary-cool-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Mauve',
hex: '#4D3762',
rgb: '77, 55, 98',
hsl: '271, 28, 30',
cmyk: '21, 44, 0, 62',
swatchClass: 'bg-secondary-mauve',
textClass: 'text-primary-warm-white'
}
] as const

View File

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

View File

@@ -823,7 +823,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud - supported by default',
en: 'Custom-node packs on Comfy Cloud supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -1845,8 +1845,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -1864,26 +1864,10 @@ const translations = {
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPromptMoodboard': {
en: 'turn the brief in this email into a 6-up moodboard',
'zh-CN': '把这封邮件里的需求做成六宫格情绪板'
},
'mcp.hero.demoPromptConcepts': {
en: 'sketch three concept frames for the launch page',
'zh-CN': '为发布页画三张概念稿'
},
'mcp.hero.demoPromptKeyart': {
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.demoPromptPbr': {
en: 'make a tileable asphalt PBR material, all 5 maps',
'zh-CN': '生成可平铺的沥青 PBR 材质,共 5 张贴图'
},
'mcp.hero.demoPromptUpscale': {
en: 'upscale the neon kaiju shot to 4K',
'zh-CN': '把霓虹怪兽画面放大到 4K'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
@@ -1892,6 +1876,10 @@ const translations = {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
@@ -1909,90 +1897,60 @@ const translations = {
'zh-CN': '放大图像'
},
// MCP SetupSection
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP',
'zh-CN': '配置 Comfy MCP'
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
'mcp.setup.option1.title': {
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.option1.command': {
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.option1.description': {
'mcp.setup.step1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
'mcp.setup.option2.title': {
en: 'Install manually',
'zh-CN': '手动安装'
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Or add it by hand',
'zh-CN': '手动添加'
},
'mcp.setup.option2.description': {
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
},
'mcp.setup.option2.tabsLabel': {
en: 'Pick your client',
'zh-CN': '选择你的客户端'
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
},
'mcp.setup.clients.claudeCode.step': {
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.clients.claudeDesktop.step': {
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
'zh-CN':
'点击侧边栏的 Customize进入 Connectors选择添加自定义连接器粘贴上方 URL 并登录。'
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
},
'mcp.setup.clients.cursor.step': {
en: 'Add the URL above to ~/.cursor/mcp.json with an X-API-Key header. Create your key at ',
'zh-CN':
'将上方 URL 添加到 ~/.cursor/mcp.json并附带 X-API-Key 请求头。在此创建密钥:'
},
'mcp.setup.clients.cursor.linkLabel': {
en: 'platform.comfy.org',
'zh-CN': 'platform.comfy.org'
},
'mcp.setup.clients.codex.step': {
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
},
'mcp.setup.clients.other.name': {
en: 'Other clients',
'zh-CN': '其他客户端'
},
'mcp.setup.clients.other.step': {
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
'zh-CN':
'将上方 URL 添加为远程 MCP 服务器。客户端不支持 OAuth改用 X-API-Key 请求头。完整教程见'
},
'mcp.setup.clients.other.linkLabel': {
en: 'setup docs',
'zh-CN': '设置文档'
},
'mcp.setup.skillsNote': {
en: 'Using Claude Code? The Comfy skills plugin adds ready-made slash commands. ',
'zh-CN': '在用 Claude CodeComfy 技能插件提供现成的斜杠命令。'
},
'mcp.setup.skillsLink': {
en: 'View on GitHub',
'zh-CN': '在 GitHub 上查看'
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
},
// MCP WhyBuildSection
@@ -2013,9 +1971,9 @@ const translations = {
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Claude Code, Claude Desktop, and Codex sign in with OAuth; every other agent connects with an API key.',
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude CodeClaude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。'
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude CodeClaude Desktop,更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
@@ -2079,53 +2037,14 @@ const translations = {
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Submit graphs, track jobs, and pull outputs back. Save and share workflows, reuse a saved one, or open any run on the ComfyUI canvas — the full engine, driven by tool calls.',
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'提交计算图、跟踪任务并取回输出。保存和分享工作流,复用已保存的工作流,或在 ComfyUI 画布上打开任意运行——完整的引擎,由工具调用驱动。'
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
'mcp.tools.4.title': {
en: 'Direct any model',
'zh-CN': '直接调用任意模型'
},
'mcp.tools.4.description': {
en: 'Kling, Veo, Seedance, Flux, GPT-Image, Nano Banana, and ElevenLabs. Closed partner APIs and open-source models, reached through one set of tools.',
'zh-CN':
'Kling、Veo、Seedance、Flux、GPT-Image、Nano Banana 和 ElevenLabs。封闭的合作伙伴 API 与开源模型,通过同一套工具即可调用。'
},
'mcp.tools.4.alt': {
en: 'Comfy MCP directing closed partner APIs and open-source models through one set of tools',
'zh-CN': 'Comfy MCP 通过同一套工具调用封闭合作伙伴 API 和开源模型'
},
'mcp.tools.5.title': {
en: 'Generate in batches',
'zh-CN': '批量生成'
},
'mcp.tools.5.description': {
en: 'Stack a batch on the Queue, track it, and pull back every output. Dozens of runs from a single call.',
'zh-CN':
'将一批任务加入队列,跟踪进度,并取回每一个输出。一次调用即可完成数十次运行。'
},
'mcp.tools.5.alt': {
en: 'Comfy MCP stacking a batch on the Queue and pulling back every output',
'zh-CN': 'Comfy MCP 将一批任务加入队列并取回每个输出'
},
'mcp.tools.6.title': {
en: 'Ship it as an app',
'zh-CN': '作为应用发布'
},
'mcp.tools.6.description': {
en: 'Turn any workflow into an app with a shareable URL. Collaborators run it in the browser — only the inputs you expose, nothing to install.',
'zh-CN':
'将任意工作流变成带可分享链接的应用。协作者在浏览器中运行——只暴露你开放的输入,无需安装任何东西。'
},
'mcp.tools.6.alt': {
en: 'Comfy MCP turning a workflow into a shareable browser app',
'zh-CN': 'Comfy MCP 将工作流变成可在浏览器中分享的应用'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
@@ -2172,81 +2091,71 @@ const translations = {
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: "For Claude Code, Claude Desktop, or Codex, add https://cloud.comfy.org/mcp as a custom connector or remote MCP server in any client, then sign in when prompted.\nFor clients that don't support OAuth, connect with a Comfy API key. Send the docs https://docs.comfy.org/agent-tools/cloud to your agent and it will figure out the installation for you.",
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'对于 Claude CodeClaude Desktop 或 Codex在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。'
'目前支持 Claude CodeClaude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: "What's the server URL?",
'zh-CN': '服务器 URL 是什么?'
},
'mcp.faq.2.a': {
en: 'https://cloud.comfy.org/mcp — add it as a custom connector or remote MCP server in any client, then sign in when prompted.',
'zh-CN':
'https://cloud.comfy.org/mcp——在任意客户端中将它添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。'
},
'mcp.faq.3.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.3.a': {
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude CodeClaude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
'Claude CodeClaude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: 'Does it cost anything?',
'zh-CN': '需要付费吗?'
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: "Connecting is free with a Comfy account, and searching models, nodes, and templates doesn't cost credits. Running a generation uses Comfy Cloud credits and needs a subscription or credit balance. Your agent confirms with you before it spends.",
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。'
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'Can I use it with my local ComfyUI?',
'zh-CN': '可以配合我的本地 ComfyUI 使用吗'
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接'
},
'mcp.faq.5.a': {
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli',
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'即将推出。目前,若要操作本地 ComfyUI你可以使用 comfy-clihttps://github.com/Comfy-Org/comfy-cli'
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.6.a': {
en: "• Generate images, video, audio, and 3D — including all open-source workflows and partner models like Seedance, GPT-Image, Nano Banana, and Kling\n• Build, edit, and run workflows; save and re-run workflows\n• Run and read in large batches\n• Search models, nodes, and template workflows\n• Read and execute shared workflow URLs\n• Upload and download assets for you\n\nEverything is now in natural language. No nodes, no downloads, no GPU, no node graphs if you don't want them.",
'zh-CN':
'• 生成图像、视频、音频和 3D——包括所有开源工作流以及 Seedance、GPT-Image、Nano Banana 和 Kling 等合作伙伴模型\n• 构建、编辑和运行工作流;保存并重新运行工作流\n• 大批量运行和读取\n• 搜索模型、节点和模板工作流\n• 读取并执行分享的工作流链接\n• 为你上传和下载资产\n\n现在一切都用自然语言完成。如果你愿意无需节点、无需下载、无需 GPU、无需节点图。'
},
'mcp.faq.7.q': {
en: 'Where do my outputs go?',
'zh-CN': '我的输出会保存到哪里?'
},
'mcp.faq.7.a': {
en: 'Into your Comfy Cloud asset library, so you can reuse, remix, and share them — and open any run on the canvas to keep editing. You can also ask your agent to download the assets locally for you.',
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。'
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Do slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.8.a': {
en: 'No. They ship with the Claude Code comfy-cloud plugin. Desktop connects to the same MCP server, so every tool works; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令随 Claude Code 的 comfy-cloud 插件一起提供。Claude Desktop 连接的是同一个 MCP 服务器,因此所有工具都能使用;直接用自然语言提问即可。'
},
'mcp.faq.9.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.9.a': {
en: 'Yes. Comfy Cloud MCP is in open beta and available to everyone with a Comfy account.',
'zh-CN':
'是的。Comfy Cloud MCP 目前处于公开测试阶段,任何拥有 Comfy 账户的人都可以使用。'
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
@@ -2272,7 +2181,6 @@ const translations = {
'nav.youtube': { en: 'YouTube', 'zh-CN': 'YouTube' },
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
@@ -3575,8 +3483,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3988,8 +3896,8 @@ const translations = {
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
en: 'Enterprise MSA Comfy',
'zh-CN': 'Enterprise MSA Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
@@ -4453,8 +4361,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4479,8 +4387,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',
@@ -4538,161 +4446,6 @@ const translations = {
'launches.section.title': {
en: 'Latest Launches',
'zh-CN': '最新发布'
},
// Brand Portal page (/brand)
'brand.page.title': {
en: 'Brand — Comfy',
'zh-CN': '品牌 — Comfy'
},
'brand.page.description': {
en: 'The Comfy brand portal: logos, color, typography, and voice. Everything you need to build something that looks and sounds like Comfy.',
'zh-CN':
'Comfy 品牌门户:标志、色彩、字体与语调。打造与 Comfy 观感一致、表达一致所需的一切。'
},
'brand.hero.label': {
en: 'Brand Portal',
'zh-CN': '品牌门户'
},
'brand.hero.heading': {
en: 'Create with ComfyUI',
'zh-CN': '用 ComfyUI 创作'
},
'brand.hero.subheading': {
en: 'Logo, color, type, and voice. Everything you need to build something that looks and sounds like us.',
'zh-CN': '标志、色彩、字体与语调。打造与我们观感一致、表达一致所需的一切。'
},
'brand.hero.viewGuidelines': {
en: 'View brand guidelines',
'zh-CN': '查看品牌规范'
},
'brand.hero.downloadLogos': {
en: 'Download logos',
'zh-CN': '下载标志'
},
'brand.logos.heading': {
en: 'One mark, many dimensions.',
'zh-CN': '一个标志,多种维度。'
},
'brand.logos.subheading': {
en: 'Logos come in light and dark options. Use as provided. Do not distort, recolor, or outline. Make sure the logo is legible against its background.',
'zh-CN':
'标志提供浅色和深色两种版本。请按原样使用,不要变形、改色或描边。确保标志在其背景上清晰可辨。'
},
'brand.colors.heading': {
en: 'Every color earns its place.',
'zh-CN': '每种颜色都各得其所。'
},
'brand.colors.subheading': {
en: 'Our color palette helps build brand recognition. When people think of Comfy, we want them to associate it with the following colors.',
'zh-CN':
'我们的调色板有助于建立品牌辨识度。当人们想到 Comfy 时,我们希望他们联想到以下这些颜色。'
},
'brand.colors.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'brand.colors.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
'brand.voice.heading': {
en: 'Precise, never cute.',
'zh-CN': '精准,绝不卖弄。'
},
'brand.voice.direct.title': {
en: 'Direct',
'zh-CN': '直接'
},
'brand.voice.direct.body': {
en: 'We state things. We dont hedge, qualify, or suggest. Short sentences. Active voice. One idea at a time.',
'zh-CN':
'我们直陈其事。不含糊、不设限、不暗示。短句。主动语态。一次只讲一个观点。'
},
'brand.voice.precise.title': {
en: 'Precise',
'zh-CN': '精准'
},
'brand.voice.precise.body': {
en: 'We use the real names for things. Nodes, samplers, seeds, checkpoints. We dont talk around the product or reach for metaphor when the technical term is already good.',
'zh-CN':
'我们直呼其名nodes、samplers、seeds、checkpoints。当技术术语已经足够贴切时我们不绕弯子也不借用比喻。'
},
'brand.voice.human.title': {
en: 'Human-first',
'zh-CN': '以人为先'
},
'brand.voice.human.body': {
en: 'The human creates. Comfy makes every step visible. We never write as though the AI is doing the work.',
'zh-CN':
'创作的是人。Comfy 让每一步都清晰可见。我们绝不把功劳写成是 AI 完成的。'
},
'brand.voice.antihype.title': {
en: 'Anti-hype',
'zh-CN': '拒绝浮夸'
},
'brand.voice.antihype.body': {
en: 'We dont write “stunning,” “revolutionary,” or “effortless.” We dont promise magic. Our tagline says exactly what we mean: Method, not magic.',
'zh-CN':
'我们不写“惊艳”“革命性”或“毫不费力”。我们不承诺魔法。我们的口号恰如其分:方法,而非魔法。'
},
'brand.voice.doLabel': {
en: 'Do',
'zh-CN': '推荐'
},
'brand.voice.dontLabel': {
en: 'Dont',
'zh-CN': '避免'
},
'brand.voice.do.0': {
en: 'Route your prompt through a ControlNet. Wire the output to the VAE decode.',
'zh-CN': '让你的 prompt 经过 ControlNet再将输出连接到 VAE decode。'
},
'brand.voice.do.1': {
en: 'Comfy runs on your hardware. Nothing leaves your machine.',
'zh-CN': 'Comfy 在你自己的硬件上运行。任何数据都不会离开你的机器。'
},
'brand.voice.dont.0': {
en: 'Simply connect your AI blocks and watch the magic happen!',
'zh-CN': '只需连接你的 AI 模块,见证奇迹的发生!'
},
'brand.voice.dont.1': {
en: 'Oops! Something went wrong. Please try again later.',
'zh-CN': '哎呀!出了点问题,请稍后再试。'
},
'brand.trademark.heading': {
en: 'Trademark guidelines.',
'zh-CN': '商标使用规范。'
},
'brand.trademark.body1': {
en: 'Comfy and ComfyUI are trademarks of Comfy Org. Youre welcome to reference them in content that accurately describes your work with our platform. Tutorials, reviews, integrations, and affiliate content all qualify.',
'zh-CN':
'Comfy 和 ComfyUI 是 Comfy Org 的商标。欢迎在准确描述你与我们平台相关工作的内容中引用它们。教程、评测、集成以及联盟内容均可。'
},
'brand.trademark.body2': {
en: 'A few rules: dont modify the logo, dont use the Comfy name in your own product or company name, and dont present your content in a way that implies official endorsement or partnership beyond whats been agreed.',
'zh-CN':
'几条规则:不要修改标志,不要在你自己的产品或公司名称中使用 Comfy 这一名称,也不要以暗示官方认可或合作关系(超出双方已达成的约定)的方式呈现你的内容。'
},
'brand.trademark.body3': {
en: 'For permissions outside these guidelines,',
'zh-CN': '如需本规范之外的授权,请'
},
'brand.trademark.contact': {
en: 'Contact Us',
'zh-CN': '联系我们'
},
'brand.questions.heading': {
en: 'Questions?',
'zh-CN': '有疑问?'
},
'brand.questions.body': {
en: 'For press, partnerships, or anything outside these guidelines,',
'zh-CN': '如涉及媒体、合作,或本规范未涵盖的任何事宜,请'
},
'brand.questions.contact': {
en: 'Contact Us',
'zh-CN': '联系我们'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import BrandBackground from '../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('brand.page.title', locale)}
description={t('brand.page.description', locale)}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection />
<BrandLogosSection />
<BrandColorSection client:visible />
<BrandVoiceSection />
<BrandTrademarkSection />
<BrandQuestionsSection />
</div>
</BaseLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../utils/jsonLd'
@@ -24,14 +23,14 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Download Comfy Desktop - Run AI on Your Hardware"
title="Download Comfy Desktop Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import BrandBackground from '../../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../../templates/brand/BrandQuestionsSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('brand.page.title', 'zh-CN')}
description={t('brand.page.description', 'zh-CN')}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection locale="zh-CN" />
<BrandLogosSection locale="zh-CN" />
<BrandColorSection client:visible locale="zh-CN" />
<BrandVoiceSection locale="zh-CN" />
<BrandTrademarkSection locale="zh-CN" />
<BrandQuestionsSection locale="zh-CN" />
</div>
</BaseLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
@@ -24,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
title="下载 Comfy 桌面版 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
@@ -34,7 +33,7 @@ const { siteUrl, locale } = pageContext(
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,112 +0,0 @@
<script setup lang="ts">
import {
useEventListener,
useIntersectionObserver,
useRafFn
} from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
interface Node {
x: number
y: number
vx: number
vy: number
}
const canvasEl = ref<HTMLCanvasElement | null>(null)
let dpr =
typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
const nodes: Node[] = Array.from({ length: 14 }, () => ({
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.0005,
vy: (Math.random() - 0.5) * 0.0005
}))
let ctx: CanvasRenderingContext2D | null = null
function draw() {
const el = canvasEl.value
if (!el || !ctx) return
const w = el.width
const h = el.height
ctx.clearRect(0, 0, w, h)
for (const n of nodes) {
n.x += n.vx
n.y += n.vy
if (n.x < 0 || n.x > 1) n.vx *= -1
if (n.y < 0 || n.y > 1) n.vy *= -1
}
ctx.strokeStyle = 'rgba(242, 255, 89, 0.18)'
ctx.lineWidth = dpr
const max = Math.min(w, h) * 0.22
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i]
const b = nodes[j]
const dx = (a.x - b.x) * w
const dy = (a.y - b.y) * h
const d = Math.hypot(dx, dy)
if (d < max) {
ctx.globalAlpha = 1 - d / max
ctx.beginPath()
ctx.moveTo(a.x * w, a.y * h)
const mx = ((a.x + b.x) / 2) * w
ctx.bezierCurveTo(mx, a.y * h, mx, b.y * h, b.x * w, b.y * h)
ctx.stroke()
}
}
}
ctx.globalAlpha = 1
ctx.fillStyle = 'rgba(242, 255, 89, 0.9)'
for (const n of nodes) {
ctx.beginPath()
ctx.arc(n.x * w, n.y * h, 2.5 * dpr, 0, Math.PI * 2)
ctx.fill()
}
}
// Resizing clears the canvas bitmap, so repaint immediately afterwards to keep
// the field visible even when the RAF loop is paused (off screen or
// reduced-motion). Also refresh dpr in case the window moved to another display.
function resize() {
const el = canvasEl.value
if (!el) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
el.width = el.offsetWidth * dpr
el.height = el.offsetHeight * dpr
draw()
}
const { pause, resume } = useRafFn(draw, { immediate: false })
// Only animate while the field is on screen, and honour reduced-motion by
// painting a single static frame instead of looping.
useIntersectionObserver(canvasEl, ([entry]) => {
if (prefersReducedMotion()) return
if (entry?.isIntersecting) resume()
else pause()
})
useEventListener('resize', resize)
onMounted(() => {
ctx = canvasEl.value?.getContext('2d') ?? null
resize()
})
</script>
<template>
<canvas
ref="canvasEl"
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-screen w-full"
/>
</template>

View File

@@ -1,93 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { brandColors } from '../../data/brandColors'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const specRows = ['hex', 'rgb', 'hsl', 'cmyk'] as const
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
const copiedHex = ref<string | null>(null)
const copiedValue = ref('')
function copyValue(hex: string, value: string) {
copiedHex.value = hex
copiedValue.value = value
void copy(value)
}
function isCardCopied(hex: string) {
return copied.value && copiedHex.value === hex
}
const liveMessage = computed(() =>
copied.value ? `${t('brand.colors.copied', locale)} ${copiedValue.value}` : ''
)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.colors.heading', locale) }}
<template #subtitle>
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
{{ t('brand.colors.subheading', locale) }}
</p>
</template>
</SectionHeader>
<span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
<ul class="mt-10 grid grid-cols-2 gap-4 lg:grid-cols-5">
<li
v-for="color in brandColors"
:key="color.hex"
:class="
cn(
'flex min-h-[123px] cursor-pointer flex-col rounded-[30px] p-6',
color.swatchClass,
color.textClass,
color.wide && 'lg:col-span-2',
color.border && 'border-primary-warm-gray border-[0.783px]'
)
"
@click="copyValue(color.hex, color.hex)"
>
<div
v-if="isCardCopied(color.hex)"
class="flex flex-1 items-center justify-center text-center text-sm font-semibold"
aria-hidden="true"
>
{{ t('brand.colors.copied', locale) }} {{ copiedValue }}
</div>
<template v-else>
<span class="text-xs font-semibold">{{ color.name }}</span>
<dl
class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 text-xs leading-[1.4]"
>
<template v-for="row in specRows" :key="row">
<dt class="uppercase opacity-50">{{ row }}</dt>
<dd>
<button
type="button"
:aria-label="`${t('brand.colors.copy', locale)} ${row} ${color[row]}`"
class="cursor-pointer text-left hover:underline"
@click.stop="copyValue(color.hex, color[row])"
>
{{ color[row] }}
</button>
</dd>
</template>
</dl>
</template>
</li>
</ul>
</section>
</template>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import Button from '../../components/ui/button/Button.vue'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../../data/brandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-4 pb-10 text-center lg:px-20 lg:pb-12"
>
<p
class="text-primary-comfy-yellow text-sm font-extrabold tracking-[0.7px] uppercase"
>
{{ t('brand.hero.label', locale) }}
</p>
<h1
class="lg:text-6.5xl mx-auto mt-6 max-w-4xl text-4xl leading-[1.3] font-light tracking-[-0.03em] text-primary-comfy-canvas md:text-5xl"
>
{{ t('brand.hero.heading', locale) }}
</h1>
<p
class="mx-auto mt-6 max-w-2xl text-[17px] leading-[1.6] font-light text-primary-comfy-canvas/80"
>
{{ t('brand.hero.subheading', locale) }}
</p>
<div
class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"
>
<Button
as="a"
:href="BRAND_GUIDELINES_PDF"
target="_blank"
rel="noopener noreferrer"
variant="default"
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
>
{{ t('brand.hero.viewGuidelines', locale) }}
</Button>
<Button
as="a"
:href="BRAND_ASSETS_ZIP"
download
variant="outline"
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
>
{{ t('brand.hero.downloadLogos', locale) }}
</Button>
</div>
</section>
</template>

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const assets = affiliateBrandAssets.map((asset) =>
asset.id === 'icon' ? { ...asset, preview: '/icons/comfyicon.svg' } : asset
)
</script>
<template>
<section id="logos" class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.logos.heading', locale) }}
<template #subtitle>
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
{{ t('brand.logos.subheading', locale) }}
</p>
</template>
</SectionHeader>
<ul class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<li
v-for="asset in assets"
:key="asset.id"
class="flex min-h-60 flex-col rounded-[30px] border-[1.5px] border-white/8 lg:min-h-[285px]"
>
<div class="flex flex-1 items-center justify-center p-8">
<img
:src="asset.preview"
:alt="asset.title[locale]"
:class="
cn(
'object-contain',
asset.id === 'icon'
? 'size-24 rounded-[23%] border border-white/10'
: 'max-h-24 max-w-[75%]'
)
"
loading="lazy"
decoding="async"
/>
</div>
<p
class="pb-8 text-center text-[21px] font-medium tracking-[1.05px] text-primary-comfy-canvas"
>
{{ asset.title[locale] }}
</p>
</li>
</ul>
</section>
</template>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import Button from '../../components/ui/button/Button.vue'
import { externalLinks } from '../../config/routes.ts'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20 lg:pt-12 lg:pb-32"
>
<SectionHeader align="start" max-width="xl">
{{ t('brand.questions.heading', locale) }}
</SectionHeader>
<p class="text-primary-warm-gray mt-6 max-w-2xl text-sm leading-[1.6]">
{{ t('brand.questions.body', locale) }}
<Button
as="a"
variant="inline"
:href="externalLinks.support"
target="_blank"
rel="noopener noreferrer"
>
{{ t('brand.questions.contact', locale) }}
</Button>
</p>
</section>
</template>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import Button from '../../components/ui/button/Button.vue'
import { externalLinks } from '../../config/routes.ts'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.trademark.heading', locale) }}
</SectionHeader>
<div
class="text-primary-warm-gray mt-6 flex max-w-4xl flex-col gap-4 text-sm leading-[1.6]"
>
<p>{{ t('brand.trademark.body1', locale) }}</p>
<p>{{ t('brand.trademark.body2', locale) }}</p>
<p>
{{ t('brand.trademark.body3', locale) }}
<Button
as="a"
variant="inline"
:href="externalLinks.support"
target="_blank"
rel="noopener noreferrer"
>
{{ t('brand.trademark.contact', locale) }}
</Button>
</p>
</div>
</section>
</template>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const principles = [
{
title: t('brand.voice.direct.title', locale),
body: t('brand.voice.direct.body', locale)
},
{
title: t('brand.voice.precise.title', locale),
body: t('brand.voice.precise.body', locale)
},
{
title: t('brand.voice.human.title', locale),
body: t('brand.voice.human.body', locale)
},
{
title: t('brand.voice.antihype.title', locale),
body: t('brand.voice.antihype.body', locale)
}
]
const doExamples = [
t('brand.voice.do.0', locale),
t('brand.voice.do.1', locale)
]
const dontExamples = [
t('brand.voice.dont.0', locale),
t('brand.voice.dont.1', locale)
]
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.voice.heading', locale) }}
</SectionHeader>
<dl class="mt-10 flex max-w-4xl flex-col gap-3.5 text-sm leading-[1.6]">
<div v-for="principle in principles" :key="principle.title">
<dt class="text-primary-comfy-yellow">{{ principle.title }}</dt>
<dd class="text-primary-warm-gray">{{ principle.body }}</dd>
</div>
</dl>
<div class="mt-12 grid gap-4 md:grid-cols-2">
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
<div class="flex items-center gap-2">
<span
class="bg-primary-comfy-yellow size-2.5 rounded-full"
aria-hidden="true"
/>
<span
class="text-sm font-bold tracking-wider text-primary-comfy-canvas uppercase"
>
{{ t('brand.voice.doLabel', locale) }}
</span>
</div>
<div
v-for="example in doExamples"
:key="example"
class="bg-transparency-ink-t80 rounded-2xl p-5"
>
<p class="text-base/[1.45] text-primary-comfy-canvas">
{{ example }}
</p>
</div>
</div>
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
<div class="flex items-center gap-2">
<span
class="bg-primary-warm-gray size-2.5 rounded-full"
aria-hidden="true"
/>
<span
class="text-primary-warm-gray text-sm font-bold tracking-wider uppercase"
>
{{ t('brand.voice.dontLabel', locale) }}
</span>
</div>
<div
v-for="example in dontExamples"
:key="example"
class="bg-transparency-ink-t80 rounded-2xl p-5"
>
<p class="text-primary-warm-gray text-base/[1.45] line-through">
{{ example }}
</p>
</div>
</div>
</div>
</section>
</template>

View File

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

View File

@@ -7,40 +7,35 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
// Each cycle types the prompt that produces the card about to slide in.
const cards = [
{
promptKey: 'mcp.hero.demoPromptMoodboard',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
promptKey: 'mcp.hero.demoPromptConcepts',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
promptKey: 'mcp.hero.demoPromptKeyart',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
promptKey: 'mcp.hero.demoPromptPbr',
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
promptKey: 'mcp.hero.demoPromptUpscale',
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
@@ -70,15 +65,15 @@ function schedule(fn: () => void, ms: number) {
}, ms)
}
function typePrompt(prompt: string, onDone: () => void) {
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = prompt.slice(0, i)
if (i < prompt.length) {
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
@@ -99,8 +94,8 @@ function revealNextCard() {
return
}
// Type the next card's prompt, then slide that card in
typePrompt(t(cards[visibleCount.value].promptKey, locale), () => {
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})

View File

@@ -5,7 +5,7 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),

View File

@@ -11,10 +11,9 @@ const ctas = mcpCtas(locale)
</script>
<template>
<!-- 5rem/6.75rem = HeaderMain's rendered height (py-5 / lg:py-8) so the hero fills the viewport below the sticky nav -->
<HeroSplit01
:locale="locale"
class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"

View File

@@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.installMcp"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -1,180 +1,69 @@
<script setup lang="ts">
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ArrowUpRight } from '@lucide/vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import SectionLabel from '../../components/common/SectionLabel.vue'
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const agentCommand = t('mcp.setup.option1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
interface McpClient {
id: string
name: string
step: string
command?: string
link?: { label: string; href: string }
}
const clients: McpClient[] = [
const cards: FeatureCard[] = [
{
id: 'claude-code',
name: 'Claude Code',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
step: t('mcp.setup.clients.claudeDesktop.step', locale)
},
{
id: 'cursor',
name: 'Cursor',
step: t('mcp.setup.clients.cursor.step', locale),
link: {
label: t('mcp.setup.clients.cursor.linkLabel', locale),
href: externalLinks.apiKeys
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
},
{
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
},
{
id: 'other',
name: t('mcp.setup.clients.other.name', locale),
step: t('mcp.setup.clients.other.step', locale),
link: {
label: t('mcp.setup.clients.other.linkLabel', locale),
href: externalLinks.docsMcp
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
}
]
const copyLabel = t('ui.copy', locale)
const copiedLabel = t('ui.copied', locale)
</script>
<template>
<section
<FeatureGrid01
id="setup"
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
>
<SectionHeader
max-width="xl"
:label="t('mcp.setup.label', locale)"
align="start"
>
{{ t('mcp.setup.heading', locale) }}
<template #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ t('mcp.setup.subtitle', locale) }}
</p>
</template>
</SectionHeader>
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.option1.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option1.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-auto pt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</div>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.option2.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option2.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsRoot default-value="claude-code" class="mt-6">
<TabsList
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
class="flex flex-wrap gap-2"
>
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
>
{{ client.name }}
</TabsTrigger>
</TabsList>
<TabsContent
v-for="client in clients"
:key="client.id"
:value="client.id"
class="mt-4 flex min-h-24 flex-col gap-3"
>
<p class="text-sm text-smoke-700">
{{ client.step
}}<a
v-if="client.link"
:href="client.link.href"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ client.link.label }}</a
>
</p>
<CopyableField
v-if="client.command"
:value="client.command"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</TabsContent>
</TabsRoot>
</div>
</div>
</section>
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -7,21 +7,16 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string; fit?: 'cover' | 'contain' }
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
const tools: {
n: 1 | 2 | 3 | 4 | 5 | 6
media: ToolMedia
altKey?: TranslationKey
}[] = [
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
@@ -45,38 +40,9 @@ const tools: {
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true,
fit: 'contain'
hideControls: true
},
altKey: 'mcp.tools.3.alt'
},
{
n: 4,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/direct-any-model.png'
},
altKey: 'mcp.tools.4.alt'
},
{
n: 5,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png'
},
altKey: 'mcp.tools.5.alt'
},
{
n: 6,
media: {
type: 'video',
src: 'https://media.comfy.org/website/homepage/showcase/ui-overview.webm',
autoplay: true,
loop: true,
hideControls: true,
fit: 'contain'
},
altKey: 'mcp.tools.6.alt'
}
]

View File

@@ -1,4 +1,4 @@
import { externalLinks } from '../../config/routes'
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,13 +9,14 @@ export interface McpCta {
}
/**
* Calls-to-action for the MCP page: view the docs or jump to the on-page
* setup options. Both the hero and the "how it works" section pair install
* with docs.
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
*/
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
@@ -26,6 +27,10 @@ export function mcpCtas(locale: Locale): {
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

View File

@@ -171,31 +171,6 @@ describe('comfyUiSourceCodeNode', () => {
})
})
describe('ComfyUI entity links', () => {
it('names the home page as the canonical page for the application', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.mainEntityOfPage).toBe(`${siteUrl}/`)
})
it('links the application back to the source code emitted alongside it', () => {
const app = comfyUiApplicationNode(siteUrl)
const source = comfyUiSourceCodeNode(siteUrl)
expect(app.isBasedOn).toEqual({ '@id': source['@id'] })
})
it('claims no canonical page or source code for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.mainEntityOfPage).toBeUndefined()
expect(node.isBasedOn).toBeUndefined()
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(

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