Compare commits

..

69 Commits

Author SHA1 Message Date
DrJKL
1a1955a359 refactor: simplify store-backed widget control API
- Collapse setControlMode/setControlFilter/setControlExecuted into one
  updateWidgetControl(targetId, patch) store action
- Remove dead after-mode hasExecuted write in runWidgetControl
- Un-export internal-only WidgetControlPhase and ValueControlTarget
- Replace 'as never' cast with 'as ValueControlMode'

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 03:30:50 -07:00
DrJKL
284a47261a refactor: consolidate widget control logic into one module
Collapse the distributed control lifecycle under
src/core/graph/widgets/control/: merge registration and positional
(de)serialization into widgetControl.ts, and move widgetControlSystem.ts
into the same folder. valueControl.ts stays a pure leaf (widgetState.ts
imports its type) and runWidgetControl stays separate so litegraph
serialization does not transitively import settingStore.

Reuse the canonical applyControlValues bridge in widgetInputs.ts,
replacing two hand-rolled positional [mode, filter] decode blocks so the
layout is decoded in one place instead of three.

Amp-Thread-ID: https://ampcode.com/threads/T-019ec02d-e454-72b9-bf54-3c2912898cc8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 03:01:24 -07:00
DrJKL
cb0b7da933 fix: preserve interior promoted widget value when host slot is a null hole
Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 00:26:24 -07:00
DrJKL
a6c54c9d8a feat: project widget control rows onto the classic canvas
Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 22:31:34 -07:00
DrJKL
a80bf7dadb test: cover legacy positional widget control load compatibility
Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 22:30:57 -07:00
DrJKL
d62da6ec14 refactor: model widget control as a component, not a widget
Widget control state (mode, filter, hasExecuted) is now an ECS component
keyed by the target widget's WidgetId in widgetValueStore, replacing the
control/filter widget entities and the IS_CONTROL_WIDGET marker.

- Defer control registration like value registration: widgets carry a
  transient controlConfig, registered in BaseWidget.setNodeId.
- Preserve the positional [target, mode, filter?] widgets_values layout via
  serialize/configure interleaving (no control widgets resurrected).
- Promotion copies the interior control component once onto the host target,
  then diverges independently; no promoted/non-promoted distinction.
- Remove controlWidgetMarker, syncWidgetControl, the GraphCanvas control-label
  sweep, and control-widget creation in addValueControlWidget(s).

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 19:50:23 -07:00
DrJKL
6cd46cb1ce refactor: treat promoted control widgets as ordinary host widgets
Remove the separate promoted-control registration and persistence paths.
A promoted controllable widget now mints host-local control/filter widgets
that are plain store-backed host widgets surfaced by SubgraphNode.widgets in
order [target, control, filter], registered through the same
syncWidgetControl used by every node and persisted through the same
widgets_values array (controls have widget.serialize default).

configure() restores in one ordered pass with legacy detection for older
target-only widgets_values, preserving the quarantine override. Deletes
promoteWidgetControl and the properties.promotedControls schema/serialize/
restore special-casing.

linkedWidgets remains only a construction-time discovery detail.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 16:10:53 -07:00
DrJKL
fd26f61d8c refactor: independent store-backed control for promoted widgets
Render widget control from the store WidgetControlState keyed by the
widget's own widgetId instead of scanning linkedWidgets, so regular and
promoted widgets resolve control uniformly without tracing into a
subgraph interior.

On promotion, mint an independent host-local control (and filter) entity
seeded once from the interior widget, then queried/advanced purely by its
own ids. Persist the host control mode via a dedicated
properties.promotedControls entry, serialized and restored on configure.

linkedWidgets remains only as a construction-time discovery detail; its
non-control uses (bounding-box subwidgets, widget<->input conversion,
group nodes) are untouched.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 15:34:00 -07:00
DrJKL
579f2eab04 refactor: register widget control at value registration
Replace the ~180-line widget-control hooks installer with control
registration that piggybacks on widget value registration.

- Register control in BaseWidget.setNodeId and SubgraphNode._setWidget
  (thread interior node), deriving control/filter ids from nodeId + name
- Resolve link-fed suppression live in runWidgetControl via one graph pass
- Delete useWidgetControlHooks + GraphCanvas wiring, setInputLinked, and
  WidgetState.inputLinked
- Move isValueControlWidget into the controlWidgetMarker leaf module

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 11:41:41 -07:00
DrJKL
daf2a583bf refactor: store-backed widget control system
Replace per-widget control closures with an ECS-style component in the
widget value store. Control state is keyed by WidgetId, registration is
deferred like value registration, and values advance via a System scoped
to the queued graph.

- Add WidgetControlState component + store registration/scoping
- Add runWidgetControl System (graph-scoped, before/after phases)
- Sync controls + link state via installWidgetControlHooks
- Move control modules under core/graph/widgets/control
- Remove promotedWidgetControl special-casing

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 08:07:55 -07:00
Alexander Brown
a9bee7639b Merge branch 'main' into drjkl/no-promotions 2026-06-11 11:31:48 -07:00
DrJKL
e20ca37606 test: assert promoted textarea materializes once on convert-to-subgraph
Covers the fresh interactive-add path alongside the existing load and clone
coverage. The prior conversion test only checked internal promoted-widget
state; this asserts the user-visible textbox appears exactly once, catching
both a missing widget and the duplicate-DOM-widget leak.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb3aa-6bcd-7528-857c-1ff02c329af3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 23:58:58 -07:00
DrJKL
9e84a3f357 fix: defer promoted DOM widget materialization until host node is settled
Creating the DOMWidgetImpl during clone/configure used a transient node id,
seeding wrong nested values and leaking duplicate DOM widgets. Guard on graph
membership so the real widget is built on the post-settle resolution pass.

Rewrite subgraph tests to assert user-visible textboxes instead of internal
graph state.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb3aa-6bcd-7528-857c-1ff02c329af3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 23:13:16 -07:00
DrJKL
612b8a4e92 refactor: treat promoted subgraph DOM widgets as normal DOM widgets
Remove promoted-widget special-casing so a promoted `customtext` textarea
on a SubgraphNode is built from the same shared multiline helpers as a
normal node widget, instead of teleport/position-override manipulation.

- Drop the position-override teleport path from the DOM widget store,
  DomWidgets.vue, and DomWidget.vue
- Extract shared textarea element + behavior wiring into
  multilineTextarea.ts, reused by useStringWidget and promotion
- Replace the dependency-injection factory registry with a protected
  createPromotedHostWidget hook on SubgraphNode, overridden by the
  app-layer ComfyNode subclass (avoids the litegraph barrel import cycle
  without a global registry)
- Materialize promoted DOM widgets via createPromotedMultilineWidget,
  store-backed by widgetId and registered directly with the DOM widget
  store (SubgraphNode.widgets is a projecting getter, so addDOMWidget
  cannot be used)

Amp-Thread-ID: https://ampcode.com/threads/T-019eb3aa-6bcd-7528-857c-1ff02c329af3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 20:43:41 -07:00
DrJKL
465e4fc9ec fix: bridge canvas edits of promoted subgraph widgets to the store
Promoted host widgets are plain store-backed POJOs. On the litegraph
canvas, edits go through toConcreteWidget, which wraps the POJO in a
transient BaseWidget whose setValue writes only its own local state and
never invokes the POJO value setter, so store.setValue was never called
and the widget appeared frozen.

Add a callback to both projection sites (_projectPromotedWidget and
promotedInputWidget) that writes through to the store. BaseWidget.setValue
calls this.callback, and callback survives the constructor Object.assign,
so canvas edits now reach the host store entry.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb367-9558-7415-a41b-8fe9a7181d1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 15:10:05 -07:00
DrJKL
1aa271538d test: assert cloned subgraph node keeps edited promoted widget values
Amp-Thread-ID: https://ampcode.com/threads/T-019eb320-d163-74ae-8367-46caf297a7ed
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 14:17:01 -07:00
DrJKL
e56b770d77 fix: tie promoted widget control execution flag to widget instance
Use a WeakSet keyed by the host widget instead of a module-level Set of
widget IDs so entries are released when the graph is cleared and widgets
are recreated, avoiding stale state across workflow reloads.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb305-7f1f-77c9-b3fb-b4ade5643bac
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 12:45:11 -07:00
DrJKL
73ec166f36 fix: port promoted combo host option sync to store-backed architecture
The merge with main brought in #12692's `syncPromotedComboHostOptions`,
which refreshed promoted combo host options via the deleted `world` ECS
layer (`@/world/widgetValueIO`, `isPromotedWidgetView`, `widget.entityId`).
This branch owns promoted widget values in `useWidgetValueStore`, so that
code failed typecheck.

Resolve the host options from the concrete interior widget via
`promotedInputSource` + `resolveConcretePromotedWidget` and write the
refreshed options into the store-backed snapshot. Rewrite the reloadNodeDefs
test to build a real subgraph host with a promoted combo input instead of
the removed world entity API.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb2b6-3d9b-77cc-8e41-05cfcf7e788e
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 12:22:19 -07:00
DrJKL
c13b497f8d Merge remote-tracking branch 'origin/main' into drjkl/no-promotions 2026-06-10 11:38:36 -07:00
DrJKL
f020b4651f fix: apply control_after_generate to promoted subgraph widgets
Promoted subgraph widget values are owned by the host SubgraphNode in
useWidgetValueStore, while control_after_generate only mutated the
interior (now link-fed, dead) widget value. The displayed seed and the
queued prompt therefore never changed.

Apply control at the host level: resolve the interior control metadata
and write the next value to the host store-backed widget. Skips inputs
with an incoming link so externally-fed and nested intermediate hosts
are left untouched.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafb9-a9cb-71f3-85c8-590613527b86
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 00:40:14 -07:00
GitHub Action
7196d92049 [automated] Apply ESLint and Oxfmt fixes 2026-06-10 03:59:22 +00:00
AustinMroz
a1ff977051 Add failing seed control tests (#12749)
This is a PR with intentionally failing tests targeting the branch for
#12617

This adds 4 failing seed control tests. Several of which are unfair and
will likely be discarded
- A simple test where a subgraph is converted and the value for
`control_after_generate` is changed
- A once functional workflow where proxyWidgets were used to control the
seed
- A never functional historic workflow where a link is made to a seed
- A never functional historic workflow where a link is made to a seed
and the original control_after_generate is promoted by proxyWidget
2026-06-09 20:55:40 -07:00
DrJKL
a896db128f fix: remove invalid second type arg from fromPartial call
Amp-Thread-ID: https://ampcode.com/threads/T-019eaf98-97c4-758f-870b-1884668ed7bc
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 20:52:26 -07:00
Alexander Brown
e012aee4e2 Apply suggestion from @AustinMroz
Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-09 19:45:07 -07:00
Alexander Brown
931a102cab Apply suggestion from @DrJKL 2026-06-09 19:30:37 -07:00
Alexander Brown
de96911a09 Apply suggestion from @AustinMroz
Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-09 19:29:42 -07:00
DrJKL
fc7f88fd9e refactor: import widget state directly
Amp-Thread-ID: https://ampcode.com/threads/T-019eae6e-d7a2-70d5-8053-819cfd8b641a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 15:54:02 -07:00
DrJKL
2e36f8edf1 refactor: remove entity id shim
Amp-Thread-ID: https://ampcode.com/threads/T-019eae6e-d7a2-70d5-8053-819cfd8b641a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 15:53:59 -07:00
Alexander Brown
d7f0e74e6a Merge branch 'main' into drjkl/no-promotions 2026-06-09 15:34:29 -07:00
DrJKL
bea38f4ad9 refactor: remove widget id shim
Amp-Thread-ID: https://ampcode.com/threads/T-019eae6e-d7a2-70d5-8053-819cfd8b641a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 15:22:39 -07:00
DrJKL
d9c5bd3300 fix: validate widget ids with regex
Amp-Thread-ID: https://ampcode.com/threads/T-019eae3f-f902-741a-91f2-e84d6436d6de
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 14:29:53 -07:00
DrJKL
0af90e77e5 test: clarify legacy input migration
Amp-Thread-ID: https://ampcode.com/threads/T-019eae3a-63b6-73d1-8a57-bbed83f1d63f
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 14:15:08 -07:00
DrJKL
2c73a29e47 fix: address widget promotion review
Amp-Thread-ID: https://ampcode.com/threads/T-019eaa30-b653-7781-8d6e-a93d6560f805
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 00:21:45 -07:00
DrJKL
4fac859988 test: align promoted widget reorder checks
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 18:04:11 -07:00
DrJKL
4fb0eba5f0 fix: isolate unknown widget fallback values
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 18:00:14 -07:00
DrJKL
166f24d462 fix: clear promoted widget errors by source
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 17:37:10 -07:00
DrJKL
817cee1806 fix: preserve promoted widget labels
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 17:25:45 -07:00
DrJKL
fbc5d96a5c fix: refresh subgraph panel after promotion
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 16:56:20 -07:00
DrJKL
56225c8593 fix: resolve duplicate nested proxy widgets
Amp-Thread-ID: https://ampcode.com/threads/T-019ea986-7a67-732f-9714-5b2dd705ba54
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 16:36:33 -07:00
DrJKL
e471178861 Merge remote-tracking branch 'origin/main' into drjkl/no-promotions
# Conflicts:
#	src/composables/graph/useErrorClearingHooks.test.ts
#	src/composables/graph/useErrorClearingHooks.ts
2026-06-08 15:57:18 -07:00
DrJKL
4d020233bf refactor: store promoted widget layout
Amp-Thread-ID: https://ampcode.com/threads/T-019ea366-1ffc-75da-9467-3da7c4567614
Co-authored-by: Amp <amp@ampcode.com>
2026-06-07 17:48:57 -07:00
DrJKL
6cbef8c22d fix: bind promoted LiteGraph widgets
Amp-Thread-ID: https://ampcode.com/threads/T-019ea366-1ffc-75da-9467-3da7c4567614
Co-authored-by: Amp <amp@ampcode.com>
2026-06-07 17:23:10 -07:00
DrJKL
5e0f0cfdf9 fix: restore promoted widget resolution
Amp-Thread-ID: https://ampcode.com/threads/T-019ea366-1ffc-75da-9467-3da7c4567614
Co-authored-by: Amp <amp@ampcode.com>
2026-06-07 16:09:24 -07:00
DrJKL
eac6587bc4 test: read promoted widgets from store-backed inputs in e2e helper
getPromotedWidgets inspected widget.sourceNodeId/sourceWidgetName on
node.widgets, a denormalization the store-backed WidgetId refactor
removed, so it returned empty for every promoted widget. Resolve
promoted widgets from node.inputs by widgetId and walk the subgraph
input link for source identity, mirroring promotedInputWidgets and
resolveSubgraphInputTarget. Public helper API is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:40:40 -07:00
DrJKL
aa4b6c70c5 refactor: migrate proxy widget migration onto the shared projection
resolveSourceWidget builds the nested-subgraph source widget via
promotedInputWidget instead of a hand-rolled `as IBaseWidget` literal
carrying source fields. Consolidate the two bespoke promoted-widget test
helpers in promotionUtils.test into one thin wrapper over the real
projection, dropping the synthetic source-field shapes. PromotedWidgetSource
is now confined to the migration/schema layer where promotion sources are
persisted tuples rather than link-derivable.
2026-06-03 00:01:18 -07:00
DrJKL
93fdbab098 refactor: resolve promoted widget identity on demand, drop isPromotedWidget
Replace the isPromotedWidget duck-type and the source fields read off
widget objects with widgetPromotedSource(node, widget) in WidgetActions
(isLinked/handleHideInput) and SectionWidgets (isWidgetShownOnParents).
getInputSpecForWidget now resolves the real interior node-definition spec
for a subgraph node instead of fabricating one from store options, and
renameWidget collapses to a widgetId-keyed label-only write that never
touches name/widgetId. Delete the now-unused PromotedWidget type and
isPromotedWidget guard.
2026-06-02 23:41:53 -07:00
DrJKL
3471de82fb refactor: build subgraph parameter rows from the shared projection
Replace the hand-rolled promoted-widget builders in TabSubgraphInputs
(buildPromotedWidget/getPromotedWidgets) and SubgraphEditor
(buildPromotedDescriptors + PromotedDescriptor) with promotedInputWidgets
/promotedInputWidget. SubgraphEditor rows now carry the projected widget
plus the host input slot and resolve their interior source on demand for
demote/re-promote, so no source identity is denormalized onto the rows.
2026-06-02 23:23:33 -07:00
DrJKL
e797fc6c3a refactor: project promoted subgraph inputs through one module
Add src/core/graph/subgraph/promotedInputWidget.ts as the single
projection from a subgraph input slot to an ordinary store-backed
widget descriptor, and route useGraphNodeManager through it
(promotedInputWidgets + the shared safeWidgetMapper), deleting the
hand-rolled promotedInputToSafeWidget.

A projected widget name is now the input slot name (stable, unique,
the widgetId derives from it); the interior source widget name is
carried separately on SafeWidgetData (sourceWidgetName) only for
missing-model lookups, which key by the interior name. Drop the
redundant slotName and promotedLabel fields.
2026-06-02 23:14:03 -07:00
DrJKL
8623384c79 fix: keep promoted parameter rows stable across value edits
buildPromotedWidget read the store value at build time, so the row-list computed
reactively rebuilt on every edit, producing new descriptor objects and churning
getStableWidgetRenderKey — re-rendering the whole panel. Read type/value/options
through live getters instead so identity stays stable and value updates in place.
2026-06-02 21:05:58 -07:00
DrJKL
d01424f99e refactor: rename PromotedWidgetView type to PromotedWidget
The promoted host widget is no longer a synthetic view; reduce the type to the
promoted-source shape and rename the structural guard to isPromotedWidget.
2026-06-02 19:55:21 -07:00
DrJKL
0b99394a34 chore: remove widgetNodeTypeGuard, orphaned by the widget view deletion 2026-06-02 19:52:07 -07:00
DrJKL
2a4102690d feat: show subgraph node promoted widgets on the parameters tab
Build store-backed parameter rows for a subgraph node's promoted inputs from
node.inputs, unified with ordinary node widgets: display resolves through the
host widgetId and value writes go through widgetValueStore. Route the panel's
value write through widgetId so promoted inputs persist.
2026-06-02 19:47:35 -07:00
DrJKL
574c8d37d9 refactor: delete PromotedWidgetView class and collapse dead view guards
Remove the synthetic promoted-widget view class and PromotedWidgetViewManager.
Promoted host widgets are fully store-backed by widgetId, so the now-dead
isPromotedWidgetView branches over node.widgets/input._widget collapse to the
plain path. SubgraphNode loses its view machinery; getSlotFromWidget matches by
widgetId. Resolve the legacy selectedInput migration via resolveSubgraphInputTarget.

The structural isPromotedWidgetView guard and PromotedWidgetView type remain in
promotedWidgetTypes for the right-panel parameter components, pending their
store-driven rewrite.
2026-06-02 19:36:53 -07:00
DrJKL
7809c2faf8 feat: resolve nested promoted subgraph widgets without a widget view
Make resolveConcretePromotedWidget descend through subgraph inputs instead of
following synthetic widget views, so two-layer promotions resolve to the
deepest concrete widget. SubgraphNode assigns a widgetId to inputs whose source
is itself a promoted subgraph input, and dispatches the concrete interior
widget (not a view) on widget-promoted.
2026-06-02 18:51:24 -07:00
DrJKL
3e462b663a refactor: resolve nested proxy source via plain ref, not a widget view
The nested-subgraph branch of resolveSourceWidget now returns a plain source
ref (name/type/options/value/widgetId) instead of a PromotedWidgetView;
getSlotFromWidget still locates the backing slot by widgetId.
2026-06-02 18:40:54 -07:00
DrJKL
54a3b035b8 refactor: derive subgraph editor promoted rows from a plain descriptor
Replace the per-input PromotedWidgetView with a source descriptor built from
node inputs, and demote linked promotions by source via demotePromotedInput.
2026-06-02 18:35:36 -07:00
DrJKL
2082f5f43b refactor: build promoted subgraph widgets without a widget view
Map promoted subgraph inputs to SafeWidgetData directly from input.widgetId,
the resolved interior source widget, and widget state, instead of constructing
an ephemeral PromotedWidgetView per input.
2026-06-02 18:27:20 -07:00
DrJKL
c69f009775 chore: remove unused exports
Amp-Thread-ID: https://ampcode.com/threads/T-019e8af0-a3a2-7713-ba7e-a5579938f388
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 17:50:46 -07:00
DrJKL
749425d1ea test: update useGraphNodeManager tests to widget-id model
Remove the obsolete test that drove mapping through a persistent promoted view
on node.widgets (now empty; duplicate-named distinct identity is covered
elsewhere). Skip the two-layer nested promotion test as a documented known
limitation pending multi-level resolution support.
2026-06-02 17:41:14 -07:00
DrJKL
2157901a74 test: update useGLSLPreview getWidget mock to single widgetId arg
The impl now calls getWidget(widgetId(graphId, nodeId, name)); the test mock
still used the old three-arg signature and looked up by the wrong key. Extract
the widget name from the widget id.
2026-06-02 17:41:13 -07:00
DrJKL
b78de7c41f test: update litegraph-core tests to the empty-widgets model
SubgraphNode.widgets is now always empty (host widgets are store-backed), and
widget state nodeId is the string parsed from the widget id. Fix the BaseWidget
nodeId assertion, drop the stale subgraphNode.widgets assertion in SubgraphIO
(inputs already cover it), and remove the obsolete manual-widget-injection test.
2026-06-02 17:41:12 -07:00
DrJKL
3e8b0f56e9 fix: break litegraph init cycle via late-bound LiteGraph holder
draw.ts and BaseWidget.ts imported the LiteGraph singleton from the barrel,
which re-entered the barrel during BaseWidget initialization and evaluated
LegacyWidget extends BaseWidget before BaseWidget was defined (crashing any
entry that loaded a widget before the barrel, e.g. useGraphNodeManager tests).
Add a leaf litegraphInstance holder (type-only import) read at runtime via
litegraph(); the barrel constructs and registers the instance.
2026-06-02 17:41:11 -07:00
DrJKL
386e2d2796 refactor: derive subgraph editor promoted rows from inputs
The panel read promoted views off node.widgets, which is now empty. Build the
synth views from subgraph inputs carrying a widgetId (mirroring
useGraphNodeManager) so promoted rows, reorder, and demote work against
store-backed host widgets. Update tests to assert on promoted inputs.
2026-06-02 17:41:10 -07:00
DrJKL
914ceae014 refactor: derive subgraph widget input spec from widget state
getInputSpecForWidget resolved promoted views to read the source widget's
options. With host widgets store-backed, build the input spec from the widget's
widgetId state options (default, etc.) instead of following a view.
2026-06-02 17:41:10 -07:00
DrJKL
c87de750ca refactor: drop dead promoted-view branch from error clearing hooks
Promoted host widgets no longer surface as views on the subgraph node, so
onWidgetChanged never receives one. Remove resolvePromotedExecId and clear
errors using the node's own execution id. Drop the obsolete test that drove
clearing through a host-side promoted view.
2026-06-02 17:41:09 -07:00
DrJKL
c0fc78554a test: build promoted view directly in host-wins tests
SubgraphNode.widgets is empty, so the host-wins semantics tests can no longer
find the view there. Construct it via createPromotedWidgetView, mirroring how
useGraphNodeManager builds the synth view.
2026-06-02 17:41:08 -07:00
DrJKL
1f50a78530 refactor: finish entityId to widgetId field rename
The type rename WidgetEntityId->WidgetId left the widget field still named
entityId. Drop the entityId aliases on IBaseWidget/BaseWidget/PromotedWidgetView
and SafeWidgetData, and read widget.widgetId everywhere. Source widget name from
widget state when present so promoted host widgets render their canonical
identity instead of stale data.
2026-06-02 17:41:05 -07:00
DrJKL
5098fb8fa7 refactor: decouple widget render key and host-widget resolver from PromotedWidgetView
The render-key prefix was cosmetic and the host-widget resolver's promoted-chain
following was unreachable (subgraph host widgets never populate node.widgets).
Reduce both to view-free lookups and drop the obsolete chain-following tests.
2026-06-02 17:41:04 -07:00
DrJKL
c0b1ed9770 refactor: make widgetValueStore WidgetId-native and migrate subgraph host widgets
Rename WidgetEntityId to WidgetId throughout, delete widgetValueIO in favor
of a WidgetId-native widgetValueStore, and represent subgraph promoted host
widgets as store-backed widget state addressed by input.widgetId. Migrate
runtime consumers (graph node manager, processed widgets, right-side panel,
price/error utilities) toward widget-id state.
2026-06-02 17:40:58 -07:00
839 changed files with 16931 additions and 64897 deletions

View File

@@ -2,7 +2,6 @@ issue_enrichment:
auto_enrich:
enabled: true
reviews:
profile: assertive
high_level_summary: false
request_changes_workflow: true
auto_review:
@@ -16,11 +15,6 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -29,5 +29,3 @@ runs:
if: ${{ inputs.include_build_step == 'true' }}
shell: bash
run: pnpm build
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'

View File

@@ -1,22 +1,21 @@
name: Upsert Comment Section
description: >
Manage a consolidated PR comment with independently-updatable sections.
Multiple CI workflows can share the same comment by using the same
comment-marker and different section-names. Each workflow upserts only
its own section, leaving other sections intact.
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
Valid section names: "e2e", "preview", "screenshot-update".
inputs:
pr-number:
description: PR number to comment on
required: true
section-name:
description: 'Section identifier (e.g. "playwright", "storybook", "e2e", "preview")'
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
required: true
section-content:
description: Markdown content for this section
required: true
comment-marker:
description: Top-level HTML comment marker shared by all sections in this comment
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
required: true
token:
description: GitHub token with pull-requests write permission
@@ -39,10 +38,6 @@ runs:
const sectionContent = process.env.INPUT_SECTION_CONTENT
const commentMarker = process.env.INPUT_COMMENT_MARKER
if (!/^[a-z0-9-]+$/.test(sectionName)) {
throw new Error(`Invalid section-name: ${sectionName}`)
}
const sectionStart = `<!-- section:${sectionName}:start -->`
const sectionEnd = `<!-- section:${sectionName}:end -->`
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`

View File

@@ -109,48 +109,3 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
-e '1x00000000000000000000AA' \
dist; then
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
exit 1
fi
echo '✅ No Turnstile sitekey references found'

View File

@@ -85,16 +85,6 @@ jobs:
fi
done
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6

View File

@@ -38,15 +38,16 @@ jobs:
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Test Start — upsert playwright starting section
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: playwright
section-content: '## 🎭 Playwright: ⏳ Running...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
@@ -58,15 +59,13 @@ jobs:
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion — deploy and generate section
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
SUMMARY_FILE: playwright-section.md
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
run: |
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \
@@ -75,22 +74,5 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"$BRANCH_NAME" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
- name: Read playwright section
id: section
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('playwright-section.md') != ''
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: playwright-section.md
- name: Upsert playwright section into unified report
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: playwright
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}

View File

@@ -47,8 +47,6 @@ jobs:
- name: Build cloud frontend
run: pnpm build:cloud
env:
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
- name: Upload cloud frontend
uses: actions/upload-artifact@v6
@@ -226,7 +224,7 @@ jobs:
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting section into the unified PR report comment for non-forked PRs
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
@@ -242,16 +240,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Upsert playwright starting section into unified report
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: playwright
section-content: '## 🎭 Playwright: ⏳ Running...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting"
# Deploy and upsert final playwright section for non-forked PRs only
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
runs-on: ubuntu-latest
@@ -275,34 +274,15 @@ jobs:
pattern: playwright-report-*
path: reports
- name: Deploy reports and generate section
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
SUMMARY_FILE: playwright-section.md
BRANCH_NAME: ${{ github.head_ref }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"$BRANCH_NAME" \
"${{ github.head_ref }}" \
"completed"
- name: Read playwright section
id: section
if: ${{ !cancelled() && hashFiles('playwright-section.md') != '' }}
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: playwright-section.md
- name: Upsert playwright section into unified report
if: ${{ !cancelled() && steps.section.outputs.content != '' }}
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: playwright
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -38,15 +38,16 @@ jobs:
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Handle Storybook Start — upsert storybook starting section
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: storybook
section-content: '## 🎨 Storybook: 🚧 Building...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
@@ -57,7 +58,7 @@ jobs:
name: storybook-static
path: storybook-static
- name: Handle Storybook Completion — deploy and generate section
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -65,28 +66,9 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
SUMMARY_FILE: storybook-section.md
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"$BRANCH_NAME" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"
- name: Read storybook section
id: section
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('storybook-section.md') != ''
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: storybook-section.md
- name: Upsert storybook section into unified report
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr.outputs.number }}
section-name: storybook
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}

View File

@@ -37,14 +37,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Upsert storybook starting section into unified report
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: storybook
section-content: '## 🎨 Storybook: 🚧 Building...'
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting"
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
@@ -163,38 +164,19 @@ jobs:
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
- name: Deploy Storybook and generate section
- name: Deploy Storybook and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.storybook-build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.storybook-build.outputs.workflow-url }}
SUMMARY_FILE: storybook-section.md
BRANCH_NAME: ${{ github.head_ref }}
run: |
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"$BRANCH_NAME" \
"${{ github.head_ref }}" \
"completed"
- name: Read storybook section
id: section
if: hashFiles('storybook-section.md') != ''
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: storybook-section.md
- name: Upsert storybook section into unified report
if: steps.section.outputs.content != ''
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: storybook
section-content: ${{ steps.section.outputs.content }}
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
@@ -226,17 +208,35 @@ jobs:
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Upsert Chromatic section into unified report
uses: ./.github/actions/upsert-comment-section
- name: Update comment with Chromatic URLs
uses: actions/github-script@v8
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: chromatic
section-content: |
### 🎨 Chromatic Visual Tests
- 📊 [View Chromatic Build](${{ needs.chromatic-deployment.outputs.chromatic-build-url }})
- 📚 [View Chromatic Storybook](${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }})
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ github.token }}
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
// Find the existing Storybook comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const storybookComment = comments.find(comment =>
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
);
if (storybookComment && buildUrl && storybookUrl) {
// Append Chromatic info to existing comment
const updatedBody = storybookComment.body.replace(
/---\n(.*)$/s,
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: storybookComment.id,
body: updatedBody
});
}

View File

@@ -1,63 +0,0 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

View File

@@ -1,55 +0,0 @@
# Description: Team-gated multi-model Cursor review — a thin caller for the
# reusable workflow in Comfy-Org/github-workflows, which is the single source of
# truth for the panel, judge, prompts, and scripts. Triggered by the
# 'cursor-review' label.
#
# Access control (team-only, two layers):
# 1. Only users with triage permission or higher can apply a label in a public
# repo, so the public cannot trigger this.
# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks
# get no secrets), so CURSOR_API_KEY is reachable only on internal branches.
name: 'PR: Cursor Review'
on:
pull_request:
types: [labeled, unlabeled]
permissions:
contents: read
pull-requests: write
concurrency:
# Re-labeling cancels an in-flight run for the same PR + label.
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
cancel-in-progress: true
jobs:
cursor-review:
if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review'
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
# paths (Playwright snapshots, generated manager types).
diff_excludes: >-
:!**/package-lock.json
:!**/yarn.lock
:!**/pnpm-lock.yaml
:!**/node_modules/**
:!**/.claude/**
:!**/dist/**
:!**/vendor/**
:!**/*.generated.*
:!**/*.min.js
:!**/*.min.css
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

View File

@@ -140,8 +140,6 @@ jobs:
const legacyMarkers = [
'<!-- COMFYUI_FRONTEND_SIZE -->',
'<!-- COMFYUI_FRONTEND_PERF -->',
'<!-- PLAYWRIGHT_TEST_STATUS -->',
'<!-- STORYBOOK_BUILD_STATUS -->',
];
const comments = await github.paginate(github.rest.issues.listComments, {
@@ -162,19 +160,11 @@ jobs:
}
}
- name: Read PR report
id: report
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./pr-report.md
- name: Upsert bundle/perf/coverage section into unified report
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/upsert-comment-section
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
section-name: ci-metrics
section-content: ${{ steps.report.outputs.content }}
report-file: ./pr-report.md
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,142 +0,0 @@
name: Publish Desktop Bridge Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.1.2)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
concurrency:
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_desktop_bridge_types:
name: Publish @comfyorg/comfyui-desktop-bridge-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
env:
REF: ${{ inputs.ref }}
DEFAULT_REF: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [ -z "$REF" ]; then
REF="$DEFAULT_REF"
fi
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Verify package
id: pkg
env:
INPUT_VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
if [ "$VERSION" != "$INPUT_VERSION" ]; then
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if version already on npm
id: check_npm
env:
NAME: ${{ steps.pkg.outputs.name }}
VER: ${{ steps.pkg.outputs.version }}
shell: bash
run: |
set -euo pipefail
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ inputs.dist_tag }}
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
working-directory: packages/comfyui-desktop-bridge-types

View File

@@ -92,7 +92,9 @@ jobs:
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ needs.build.outputs.is_prerelease == 'true' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true

View File

@@ -65,7 +65,6 @@
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unreachable": "error",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
@@ -74,14 +73,12 @@
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/valid-title": "off",
"vitest/require-to-throw-message": "off",
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",

View File

@@ -78,21 +78,6 @@ const config: StorybookConfig = {
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/composables/billing/useBillingContext',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
@@ -41,6 +42,7 @@ setup((app) => {
}
}
})
app.use(ConfirmationService)
app.use(ToastService)
})

View File

@@ -1,24 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"font": "inter",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

View File

@@ -15,9 +15,7 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
@@ -47,11 +45,6 @@ test.describe('Download page @smoke', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
@@ -78,7 +71,7 @@ test.describe('Download page @smoke', () => {
})
const windowsBtn = hero.locator(
'a[href="https://comfy.org/download/windows/nsis/x64"]'
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)

View File

@@ -2,13 +2,6 @@ import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const TOP_LEVEL_LABELS = [
'Products',
'Pricing',
'Community',
'Company'
] as const
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
@@ -24,10 +17,14 @@ test.describe('Desktop navigation @smoke', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of TOP_LEVEL_LABELS) {
await expect(
desktopLinks.getByText(label, { exact: true }).first()
).toBeVisible()
for (const label of [
'PRODUCTS',
'PRICING',
'COMMUNITY',
'RESOURCES',
'COMPANY'
]) {
await expect(desktopLinks.getByText(label).first()).toBeVisible()
}
})
@@ -52,11 +49,11 @@ test.describe('Desktop dropdown @interaction', () => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: 'Products'
name: /PRODUCTS/i
})
await productsButton.hover()
const dropdown = nav.getByTestId('nav-dropdown')
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Desktop',
'Comfy Cloud',
@@ -70,20 +67,19 @@ test.describe('Desktop dropdown @interaction', () => {
test('moving mouse away closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
const viewport = page.viewportSize()
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
await page.locator('main').hover()
await expect(comfyLocal).toBeHidden()
})
test('Escape key closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
await expect(comfyLocal).toBeVisible()
@@ -109,11 +105,11 @@ test.describe('Mobile menu @mobile', () => {
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
const menu = page.locator('#site-mobile-menu')
await expect(menu).toBeVisible()
for (const label of ['Products', 'Pricing', 'Community']) {
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
await expect(menu.getByText(label).first()).toBeVisible()
}
})
@@ -122,14 +118,24 @@ test.describe('Mobile menu @mobile', () => {
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
await menu.getByRole('button', { name: 'Products' }).click()
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
})
test('CTA buttons visible in mobile menu', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -25,15 +25,12 @@
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@lucide/vue": "catalog:",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
"class-variance-authority": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
@@ -46,7 +43,6 @@
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3062_2148)">
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
</g>
<defs>
<clipPath id="clip0_3062_2148">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -30,9 +30,9 @@ function toggle(index: number) {
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
{{ heading }}
</h2>
</div>
@@ -42,7 +42,7 @@ function toggle(index: number) {
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:id="`faq-trigger-${faq.id}`"
@@ -83,7 +83,7 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
</section>

View File

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

View File

@@ -25,7 +25,7 @@ const {
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>

View File

@@ -114,7 +114,7 @@ function scrollToSection(id: string) {
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<div class="lg:flex lg:gap-16">
<!-- Desktop sticky nav -->
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
<aside class="scrollbar-none hidden lg:block lg:w-48 lg:shrink-0">
<div class="sticky top-32">
<CategoryNav
:categories="categories"
@@ -135,7 +135,7 @@ function scrollToSection(id: string) {
>
<h2
v-if="section.hasTitle"
class="mb-6 text-2xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
>
{{ t(key(section.id, 'title'), locale) }}
</h2>
@@ -144,7 +144,7 @@ function scrollToSection(id: string) {
<!-- Paragraph -->
<p
v-if="block.type === 'paragraph'"
class="mt-4 text-sm/relaxed text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
v-html="t(key(section.id, `block.${i}`), locale)"
/>
@@ -167,7 +167,7 @@ function scrollToSection(id: string) {
locale
).split('\n')"
:key="j"
class="flex items-start gap-2 text-primary-comfy-canvas"
class="text-primary-comfy-canvas flex items-start gap-2"
>
<span
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
@@ -187,7 +187,7 @@ function scrollToSection(id: string) {
locale
).split('\n')"
:key="j"
class="flex items-start gap-3 text-primary-comfy-canvas"
class="text-primary-comfy-canvas flex items-start gap-3"
>
<span
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
@@ -205,7 +205,7 @@ function scrollToSection(id: string) {
:alt="t(key(section.id, `block.${i}.alt`), locale)"
class="w-full rounded-2xl object-cover"
/>
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
{{ t(key(section.id, `block.${i}.caption`), locale) }}
</figcaption>
</figure>
@@ -221,7 +221,7 @@ function scrollToSection(id: string) {
"
>
<p
class="text-lg/relaxed font-light text-primary-comfy-canvas italic"
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
>
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
</p>
@@ -238,17 +238,17 @@ function scrollToSection(id: string) {
<SectionLabel>
{{ t(key(section.id, `block.${i}.label`), locale) }}
</SectionLabel>
<p class="mt-2 text-sm font-semibold text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
{{ t(key(section.id, `block.${i}.name`), locale) }}
</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
{{ t(key(section.id, `block.${i}.role`), locale) }}
</p>
<template v-if="hasKey(key(section.id, `block.${i}.name2`))">
<p class="mt-4 text-sm font-semibold text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-4 text-sm font-semibold">
{{ t(key(section.id, `block.${i}.name2`), locale) }}
</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
{{ t(key(section.id, `block.${i}.role2`), locale) }}
</p>
</template>

View File

@@ -40,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -66,10 +66,10 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label[locale] }}
</span>

View File

@@ -1,85 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import { externalLinks, getRoutes } from '../../../config/routes.ts'
import GitHubStarBadge from '../GitHubStarBadge.vue'
import HeaderMainDesktop from './HeaderMainDesktop.vue'
import HeaderMainMobile from './HeaderMainMobile.vue'
import Button from '@/components/ui/button/Button.vue'
const { locale = 'en', githubStars = '' } = defineProps<{
locale?: Locale
githubStars?: string
}>()
const routes = getRoutes(locale)
const ctaButtons = [
{
prefix: t('nav.ctaDesktopPrefix', locale),
core: t('nav.ctaDesktopCore', locale),
ariaLabel: t('nav.downloadLocal', locale),
href: routes.download,
primary: false
},
{
prefix: t('nav.ctaCloudPrefix', locale),
core: t('nav.ctaCloudCore', locale),
ariaLabel: t('nav.launchCloud', locale),
href: externalLinks.cloud,
primary: true
}
]
</script>
<template>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a
:href="routes.home"
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
aria-label="Comfy home"
>
<img
src="/icons/logomark.svg"
alt="Comfy"
class="col-span-full row-span-full h-8"
/>
<div
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
>
<img
src="/icons/logo.svg"
alt="Comfy"
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
/>
</div>
</a>
<!-- Desktop nav links -->
<HeaderMainDesktop :locale class="hidden lg:block" />
<HeaderMainMobile :locale class="lg:hidden" />
<!-- Desktop CTA buttons -->
<div
data-testid="desktop-nav-cta"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
<Button
v-for="cta in ctaButtons"
:key="cta.href"
as="a"
:href="cta.href"
:variant="cta.primary ? 'default' : 'outline'"
:aria-label="cta.ariaLabel"
>
<span
><span class="hidden xl:inline-block">{{ cta.prefix }}&nbsp;</span
>{{ cta.core }}</span
>
</Button>
</div>
</nav>
</template>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import NavigationMenu from '@/components/ui/navigation-menu/NavigationMenu.vue'
import NavigationMenuContent from '@/components/ui/navigation-menu/NavigationMenuContent.vue'
import NavigationMenuItem from '@/components/ui/navigation-menu/NavigationMenuItem.vue'
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
import NavigationMenuList from '@/components/ui/navigation-menu/NavigationMenuList.vue'
import NavigationMenuTrigger from '@/components/ui/navigation-menu/NavigationMenuTrigger.vue'
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu/navigationMenuTriggerStyle'
import {
isHrefActive,
useCurrentPath
} from '../../../composables/useCurrentPath'
import { getMainNavigation } from '../../../data/mainNavigation'
import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
const currentPath = useCurrentPath()
function isNavItemActive(navItem: NavItem, path: string): boolean {
if (navItem.href) return isHrefActive(navItem.href, path)
return (
navItem.columns?.some((column) =>
column.items.some((item) => isHrefActive(item.href, path))
) ?? false
)
}
</script>
<template>
<NavigationMenu data-testid="desktop-nav-links">
<NavigationMenuList>
<NavigationMenuItem
v-for="navItem in mainNavigation"
:key="navItem.label"
>
<template v-if="navItem.columns?.length">
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
{{ navItem.label }}
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">
<NavFeaturedCard
v-if="navItem.featured"
:featured="navItem.featured"
/>
<NavColumn
v-for="column in navItem.columns"
:key="column.header"
:column="column"
:locale="locale"
:current-path="currentPath"
/>
</ul>
</NavigationMenuContent>
</template>
<NavigationMenuLink
v-else
as-child
:active="isNavItemActive(navItem, currentPath)"
:class="navigationMenuTriggerStyle()"
>
<a :href="navItem.href" class="ppformula-text-center">{{
navItem.label
}}</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</template>

View File

@@ -1,167 +0,0 @@
<script setup lang="ts">
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { getMainNavigation } from '../../../data/mainNavigation'
import { getRoutes } from '../../../config/routes.ts'
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 Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
const mainNavigation = getMainNavigation(locale)
const isOpen = ref(false)
const activeSection = ref<string | null>(null)
const activeItem = computed(() =>
mainNavigation.find(
(item) => item.label === activeSection.value && item.columns
)
)
watch(isOpen, (open) => {
if (open) {
lockScroll()
} else {
unlockScroll()
activeSection.value = null
}
})
onUnmounted(() => {
if (isOpen.value) unlockScroll({ skipRestore: true })
})
</script>
<template>
<div>
<Sheet v-model:open="isOpen">
<SheetTrigger
:aria-label="t('nav.toggleMenu', locale)"
class="bg-primary-comfy-yellow grid size-10 shrink-0 cursor-pointer place-items-center rounded-xl text-primary-comfy-ink hover:opacity-90"
>
<BreadthumbIcon class="h-3 w-5 text-primary-comfy-ink" />
</SheetTrigger>
<SheetContent
side="right"
class="flex size-full flex-col px-6 py-5 sm:max-w-none"
:close-label="t('nav.close', locale)"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t('nav.menu', locale) }}</SheetTitle>
<SheetDescription>
{{ t('nav.mobileMenuDescription', locale) }}
</SheetDescription>
</SheetHeader>
<div>
<a
:href="routes.home"
class="focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex w-auto shrink-0 focus-visible:ring-3"
>
<img src="/icons/logomark.svg" alt="" class="h-11 w-auto" />
<span class="sr-only">{{ t('nav.home', locale) }}</span>
</a>
</div>
<div class="relative mt-4 flex-1 overflow-hidden">
<!-- Top-level nav -->
<nav
:class="
cn(
'absolute inset-0 overflow-y-auto p-1',
activeItem ? 'opacity-0' : ''
)
"
:aria-label="t('nav.menu', locale)"
:inert="activeItem ? true : undefined"
>
<ul class="flex flex-col gap-y-8">
<li v-for="item in mainNavigation" :key="item.label">
<Button
:as="item.columns ? 'button' : 'a'"
variant="navMuted"
:type="item.columns ? 'button' : undefined"
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
{{ item.label }}
<template #append>
<ChevronRight class="size-7" />
</template>
</Button>
</li>
</ul>
</nav>
<!-- Drill-down sub-panel -->
<div
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
:class="
activeItem
? 'translate-x-0'
: 'pointer-events-none translate-x-full'
"
:inert="activeItem ? undefined : true"
:aria-hidden="!activeItem"
>
<div class="size-full overflow-y-auto py-8">
<Button
type="button"
variant="link"
@click="activeSection = null"
>
<template #prepend>
<ChevronLeft />
</template>
{{ t('nav.back', locale) }}
</Button>
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
<div
v-for="column in activeItem.columns"
:key="column.header"
class="flex flex-col gap-y-3"
>
<p
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
>
{{ column.header }}
</p>
<Button
v-for="link in column.items"
:key="link.label"
:href="link.href"
variant="nav"
as="a"
:target="link.external ? '_blank' : undefined"
:rel="link.external ? 'noopener noreferrer' : undefined"
>
<NavLinkContent :item="link" :locale="locale" />
</Button>
</div>
</div>
</div>
<div
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLink.vue'
import { isHrefActive } from '../../../composables/useCurrentPath'
import type { NavColumn } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavLinkContent from './NavLinkContent.vue'
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
</script>
<template>
<li class="flex flex-col space-y-4">
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
{{ column.header }}
</p>
<ul class="flex flex-col">
<li v-for="item in column.items" :key="item.label">
<NavigationMenuLink
as-child
:active="isHrefActive(item.href, currentPath)"
class="hover:bg-transparency-white-t4"
>
<a
:href="item.href"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener noreferrer' : undefined"
class="whitespace-nowrap"
>
<NavLinkContent :item="item" :locale="locale" />
</a>
</NavigationMenuLink>
</li>
</ul>
</li>
</template>

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
import ButtonPill from '@/components/ui/button-pill/ButtonPill.vue'
import type { NavFeatured } from '../../../data/mainNavigation'
defineProps<{ featured: NavFeatured }>()
</script>
<template>
<li class="shrink-0">
<a
:href="featured.cta.href"
:aria-label="featured.cta.ariaLabel"
class="group/pill-trigger relative block"
>
<img
class="aspect-4/3 w-62 max-w-none rounded-xl"
:src="featured.imageSrc"
:alt="featured.imageAlt ?? ''"
/>
<p class="mt-4 font-extrabold uppercase">
{{ featured.title }}
</p>
<div class="mt-1">
<ButtonPill as="span" icon-position="left" variant="ghost">
{{ featured.cta.label }}
</ButtonPill>
</div>
</a>
</li>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import BrandButton from './BrandButton.vue'
import type { NavLink } from './NavDesktopLink.vue'
interface CtaLink {
label: string
href: string
primary: boolean
}
const {
open = false,
navigating = false,
links = [],
ctaLinks = [],
locale = 'en'
} = defineProps<{
open?: boolean
navigating?: boolean
links?: NavLink[]
ctaLinks?: CtaLink[]
locale?: Locale
}>()
const emit = defineEmits<{
close: []
}>()
const menuRef = ref<HTMLElement | undefined>()
const activeSection = ref<string | null>(null)
const activeSectionItems = computed(
() => links.find((l) => l.label === activeSection.value)?.items
)
function onNavigate() {
activeSection.value = null
emit('close')
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab') return
const menu = menuRef.value
if (!menu) return
const focusable = [...menu.querySelectorAll<HTMLElement>(FOCUSABLE)]
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(
() => open,
async (isOpen) => {
if (isOpen) {
lockScroll()
await nextTick()
const menu = menuRef.value
const firstFocusable = menu?.querySelector<HTMLElement>(FOCUSABLE)
firstFocusable?.focus()
menu?.addEventListener('keydown', trapFocus)
} else {
menuRef.value?.removeEventListener('keydown', trapFocus)
unlockScroll({ skipRestore: navigating })
}
}
)
onUnmounted(() => {
menuRef.value?.removeEventListener('keydown', trapFocus)
if (open) unlockScroll({ skipRestore: true })
})
</script>
<template>
<div
v-show="open"
id="site-mobile-menu"
ref="menuRef"
role="dialog"
aria-modal="true"
:inert="!open"
:aria-label="t('nav.menu', locale)"
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 lg:hidden"
>
<!-- Main list -->
<template v-if="!activeSection">
<div class="flex flex-1 flex-col gap-8">
<template v-for="link in links" :key="link.label">
<button
v-if="link.items"
class="text-primary-comfy-canvas text-left text-3xl font-medium"
@click="activeSection = link.label"
>
{{ link.label }}
</button>
<a
v-else
:href="link.href"
class="text-primary-comfy-canvas text-3xl font-medium"
@click="onNavigate"
>
{{ link.label }}
</a>
</template>
</div>
<div class="flex flex-col gap-3">
<BrandButton
v-for="cta in ctaLinks"
:key="cta.href"
:href="cta.href"
:variant="cta.primary ? 'solid' : 'outline'"
size="lg"
class="w-full"
>
{{ cta.label }}
</BrandButton>
</div>
</template>
<!-- Drill-down sub-menu -->
<template v-else>
<div class="flex flex-1 flex-col">
<button
class="text-primary-comfy-yellow mb-6 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
@click="activeSection = null"
>
<span
aria-hidden="true"
class="bg-primary-comfy-yellow size-3 -translate-y-px rotate-180"
style="
mask: url('/icons/arrow-right.svg') center / contain no-repeat;
"
/>
{{ t('nav.back', locale) }}
</button>
<p class="text-primary-warm-gray mb-8 text-sm font-bold uppercase">
{{ activeSection }}
</p>
<div class="flex flex-col gap-8 pl-2">
<a
v-for="item in activeSectionItems"
:key="item.href"
:href="item.href"
class="text-primary-comfy-canvas flex items-center gap-3 text-3xl font-medium"
@click="onNavigate"
>
{{ item.label }}
<span
v-if="item.badge"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-semibold"
>
<span class="ppformula-text-center inline-block skew-x-12">{{
item.badge
}}</span>
</span>
<img
v-if="item.external"
src="/icons/arrow-up-right.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</a>
</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
type NavDropdownItem = {
label: string
href: string
badge?: string
external?: boolean
}
export type NavLink = {
label: string
href?: string
items?: NavDropdownItem[]
}
const {
link,
currentPath,
isOpen = false
} = defineProps<{
link: NavLink
currentPath: string
isOpen?: boolean
}>()
const emit = defineEmits<{
(e: 'open', label: string): void
(e: 'close'): void
(e: 'toggle', label: string): void
}>()
</script>
<template>
<div
class="relative"
@mouseenter="link.items?.length && emit('open', link.label)"
@mouseleave="emit('close')"
@focusin="link.items?.length && emit('open', link.label)"
@focusout="emit('close')"
>
<button
v-if="link.items?.length"
type="button"
:class="
cn(
'group flex cursor-pointer items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
link.items.some((item) => currentPath === item.href)
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
)
"
aria-haspopup="true"
:aria-expanded="isOpen"
@click="emit('toggle', link.label)"
>
{{ link.label }}
<span
aria-hidden="true"
:class="
cn(
'text-base leading-none transition-colors',
link.items.some((item) => currentPath === item.href)
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas group-hover:text-primary-warm-gray'
)
"
>
</span>
</button>
<a
v-else
:href="link.href"
:aria-current="currentPath === link.href ? 'page' : undefined"
:class="
cn(
'flex items-center gap-1.5 py-3 text-sm font-bold tracking-wide uppercase transition-colors',
currentPath === link.href
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas hover:text-primary-warm-gray'
)
"
>
{{ link.label }}
</a>
<div
v-if="link.items?.length"
v-show="isOpen"
data-testid="nav-dropdown"
class="bg-transparency-ink-t80 absolute top-full left-0 w-max rounded-xl p-2 shadow-lg backdrop-blur-2xl backdrop-saturate-150"
>
<a
v-for="item in link.items"
:key="item.href"
:href="item.href"
:aria-current="currentPath === item.href ? 'page' : undefined"
:class="
cn(
'flex items-center gap-2 rounded-sm p-2 text-xs font-medium tracking-wide transition-colors',
currentPath === item.href
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas hover:bg-transparency-white-t4 hover:text-white'
)
"
@click="emit('close')"
>
{{ item.label }}
<span
v-if="item.badge"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-[9px]/3 leading-none font-bold"
>
<span class="ppformula-text-center inline-block skew-x-12">{{
item.badge
}}</span>
</span>
<img
v-if="item.external"
src="/icons/arrow-up-right.svg"
alt=""
class="ml-auto size-4"
aria-hidden="true"
/>
</a>
</div>
</div>
</template>

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonPill from './ButtonPill.vue'
import PillButton from './PillButton.vue'
const meta: Meta<typeof ButtonPill> = {
title: 'Website/UI/ButtonPill',
component: ButtonPill,
const meta: Meta<typeof PillButton> = {
title: 'Website/Common/PillButton',
component: PillButton,
tags: ['autodocs'],
decorators: [
() => ({
@@ -12,19 +12,22 @@ const meta: Meta<typeof ButtonPill> = {
})
],
argTypes: {
as: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'a']
options: ['button', 'submit', 'reset']
},
asChild: { control: 'boolean' },
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['default', 'lg', 'icon']
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
@@ -38,57 +41,57 @@ export default meta
type Story = StoryObj<typeof meta>
export const AsAnchor: Story = {
args: { as: 'a', href: '#' },
args: { href: '#' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const AsButton: Story = {
args: { as: 'button', type: 'button' },
args: { type: 'button' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: '<ButtonPill v-bind="args">Submit</ButtonPill>'
template: '<PillButton v-bind="args">Submit</PillButton>'
})
}
export const Ghost: Story = {
args: { as: 'a', href: '#', variant: 'ghost' },
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: '<ButtonPill v-bind="args">Read More</ButtonPill>'
template: '<PillButton v-bind="args">Read More</PillButton>'
})
}
export const DefaultSolid: Story = {
args: { as: 'a', href: '#', size: 'default' },
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const LargeSolid: Story = {
args: { as: 'a', href: '#', size: 'lg' },
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: `<ButtonPill v-bind="args">Let's Collaborate</ButtonPill>`
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const WithCustomIcon: Story = {
args: { as: 'a', href: '#' },
args: { href: '#' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: `
<ButtonPill v-bind="args">
<PillButton v-bind="args">
Next Step
<template #icon>
<svg
@@ -103,55 +106,57 @@ export const WithCustomIcon: Story = {
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonPill>
</PillButton>
`
})
}
export const IconLeft: Story = {
args: { as: 'a', href: '#', iconPosition: 'left' },
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: '<ButtonPill v-bind="args">Go Back</ButtonPill>'
template: '<PillButton v-bind="args">Go Back</PillButton>'
})
}
export const RevealLabelOnHover: Story = {
args: { as: 'a', href: '#', hideLabel: true },
args: { href: '#', hideLabel: true },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: '<ButtonPill v-bind="args">Try Workflow</ButtonPill>'
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { ButtonPill },
components: { PillButton },
setup: () => ({ args }),
template: '<ButtonPill v-bind="args">Unavailable</ButtonPill>'
template: '<PillButton v-bind="args">Unavailable</PillButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { ButtonPill },
components: { PillButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<ButtonPill as="a" href="#" variant="solid" size="default">Default</ButtonPill>
<ButtonPill as="a" href="#" variant="solid" size="lg">Large</ButtonPill>
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<ButtonPill as="a" href="#" variant="ghost" size="default">Default</ButtonPill>
<ButtonPill as="a" href="#" variant="ghost" size="lg">Large</ButtonPill>
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
</div>
</div>
</div>

View File

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

View File

@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
<template>
<footer
ref="footerRef"
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
>
<div
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
breakpointsTailwind,
useBreakpoints,
useEventListener,
whenever
} from '@vueuse/core'
import { nextTick, onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import BrandButton from './BrandButton.vue'
import GitHubStarBadge from './GitHubStarBadge.vue'
import MobileMenu from './MobileMenu.vue'
import NavDesktopLink from './NavDesktopLink.vue'
import type { NavLink } from './NavDesktopLink.vue'
const { locale = 'en', githubStars = '' } = defineProps<{
locale?: Locale
githubStars?: string
}>()
const routes = getRoutes(locale)
const navLinks: NavLink[] = [
{
label: t('nav.products', locale),
items: [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{
label: t('nav.comfyApi', locale),
href: routes.api,
badge: t('nav.badgeNew', locale)
},
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
]
},
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
items: [
{
label: t('nav.comfyHub', locale),
href: externalLinks.workflows,
badge: t('nav.badgeNew', locale)
},
{ label: t('nav.gallery', locale), href: routes.gallery }
]
},
{
label: t('nav.resources', locale),
items: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
external: true
},
{
label: t('nav.github', locale),
href: externalLinks.github,
external: true
},
{
label: t('nav.discord', locale),
href: externalLinks.discord,
external: true
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
external: true
},
{
label: t('nav.youtube', locale),
href: externalLinks.youtube,
external: true
}
]
},
{
label: t('nav.company', locale),
items: [
{ label: t('nav.aboutUs', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.customerStories', locale), href: routes.customers }
]
}
]
const ctaButtons = [
{
label: t('nav.downloadLocal', locale),
prefix: 'DOWNLOAD',
core: 'DESKTOP',
href: routes.download,
primary: false
},
{
label: t('nav.launchCloud', locale),
prefix: 'LAUNCH',
core: 'CLOUD',
href: externalLinks.cloud,
primary: true
}
]
const currentPath = ref('')
const openDesktopDropdown = ref<string | null>(null)
const mobileMenuOpen = ref(false)
const isNavigating = ref(false)
const hamburgerRef = ref<HTMLButtonElement | undefined>()
function closeMobileMenu() {
mobileMenuOpen.value = false
hamburgerRef.value?.focus()
}
function toggleDesktopDropdown(label: string) {
openDesktopDropdown.value = openDesktopDropdown.value === label ? null : label
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeMobileMenu()
openDesktopDropdown.value = null
}
}
async function onNavigate() {
isNavigating.value = true
closeMobileMenu()
openDesktopDropdown.value = null
currentPath.value = window.location.pathname
await nextTick()
isNavigating.value = false
}
const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('lg')
whenever(isDesktop, () => {
mobileMenuOpen.value = false
// Don't focus hamburger when transitioning to desktop — it's hidden
})
onMounted(() => {
currentPath.value = window.location.pathname
useEventListener(document, 'keydown', onKeydown)
useEventListener(document, 'astro:after-swap', onNavigate)
})
</script>
<template>
<MobileMenu
:open="mobileMenuOpen"
:navigating="isNavigating"
:links="navLinks"
:cta-links="ctaButtons"
:locale="locale"
@close="closeMobileMenu"
/>
<nav
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a
:href="routes.home"
class="inline-grid h-10 shrink-0 grid-cols-1 grid-rows-1 transition-[width]"
aria-label="Comfy home"
>
<img
src="/icons/logomark.svg"
alt="Comfy"
class="col-span-full row-span-full h-8"
/>
<div
class="relative col-span-full row-span-full h-10 w-0 overflow-clip transition-[width] xl:w-36"
>
<img
src="/icons/logo.svg"
alt="Comfy"
class="absolute top-0 left-0 h-10 w-36 max-w-none object-contain object-left"
/>
</div>
</a>
<!-- Desktop nav links -->
<div
data-testid="desktop-nav-links"
class="hidden items-center gap-[clamp(1rem,2.5vw,2.5rem)] lg:flex"
>
<NavDesktopLink
v-for="link in navLinks"
:key="link.label"
:link="link"
:current-path="currentPath"
:is-open="openDesktopDropdown === link.label"
@open="openDesktopDropdown = $event"
@close="openDesktopDropdown = null"
@toggle="toggleDesktopDropdown"
/>
</div>
<!-- Desktop CTA buttons -->
<div
data-testid="desktop-nav-cta"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
<BrandButton
v-for="cta in ctaButtons"
:key="cta.href"
:href="cta.href"
:variant="cta.primary ? 'solid' : 'outline'"
size="nav"
:aria-label="cta.label"
>
<span
class="inline-block max-w-0 overflow-hidden align-bottom transition-[max-width] duration-300 ease-in-out xl:max-w-28"
aria-hidden="true"
>{{ cta.prefix }}&nbsp;</span
>{{ cta.core }}
</BrandButton>
</div>
<!-- Mobile hamburger -->
<button
ref="hamburgerRef"
:class="
cn(
'flex size-10 items-center justify-center rounded-xl lg:hidden',
mobileMenuOpen
? 'border-primary-comfy-yellow border-2 bg-transparent'
: 'bg-primary-comfy-yellow'
)
"
:aria-label="t('nav.toggleMenu', locale)"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<img
v-if="!mobileMenuOpen"
src="/icons/breadthumb.svg"
alt=""
class="h-3"
aria-hidden="true"
/>
<img
v-else
src="/icons/close.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</button>
</nav>
</template>

View File

@@ -15,7 +15,7 @@ import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import PlayPauseButton from './PlayPauseButton.vue'
export type VideoTrack = {
type VideoTrack = {
src: string
kind: 'subtitles' | 'captions' | 'descriptions'
srclang: string
@@ -35,7 +35,7 @@ const {
locale?: Locale
src?: string
poster?: string
tracks?: readonly VideoTrack[]
tracks?: VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean

View File

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

View File

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

View File

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

View File

@@ -58,13 +58,13 @@ function handleLogoLoad() {
</SectionLabel>
<h1
ref="headingRef"
class="mt-4 text-4xl/tight font-light text-primary-comfy-canvas lg:text-6xl"
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
>
{{ t('customers.hero.heading', locale) }}
</h1>
<p
ref="bodyRef"
class="mt-6 max-w-lg text-base text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-6 max-w-lg text-base"
>
{{ t('customers.hero.body', locale) }}
</p>
@@ -72,12 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div
id="hero-video"
ref="videoRef"
class="max-w-9xl mx-auto scroll-mt-24 px-4 pb-20 lg:scroll-mt-36 lg:px-20 lg:pb-40"
>
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -35,7 +35,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
{{ t(story.category, locale) }}
</span>
<h3
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light lg:text-xl/snug"
>
{{ t(story.title, locale) }}
</h3>

View File

@@ -19,7 +19,7 @@ const {
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
{{ t('customers.story.whatsNext' as TranslationKey, locale) }}
</h2>
@@ -35,18 +35,18 @@ const {
</a>
<div class="flex flex-col gap-6">
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
{{ title }}
</h3>
<a :href="href" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
>
{{ t('customers.story.viewArticle' as TranslationKey, locale) }}
</span>

View File

@@ -21,7 +21,7 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="mb-10 text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
</h2>
@@ -37,18 +37,18 @@ const nextHref = `${localePrefix}/demos/${nextSlug}`
</a>
<div class="flex flex-col gap-6">
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
{{ nextTitle }}
</h3>
<a :href="nextHref" class="flex items-center gap-3">
<span
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="ppformula-text-center text-sm font-semibold tracking-wider text-primary-comfy-canvas uppercase"
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
>
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
</span>

View File

@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 12"
fill="none"
aria-hidden="true"
:class="$props.class"
>
<path
d="M20 1C20 1.55228 19.5523 2 19 2H17.5C16.6716 2 16 2.67157 16 3.5C16 4.32843 16.6716 5 17.5 5H19C19.5523 5 20 5.44772 20 6C20 6.55228 19.5523 7 19 7H7.5C6.67157 7 6 7.67157 6 8.5C6 9.32843 6.67157 10 7.5 10H19C19.5523 10 20 10.4477 20 11C20 11.5523 19.5523 12 19 12H1C0.447715 12 0 11.5523 0 11C0 10.4477 0.447715 10 1 10H2.5C3.32843 10 4 9.32843 4 8.5C4 7.67157 3.32843 7 2.5 7H1C0.447715 7 0 6.55228 0 6C0 5.44772 0.447715 5 1 5H12.5C13.3284 5 14 4.32843 14 3.5C14 2.67157 13.3284 2 12.5 2H1C0.447716 2 0 1.55228 0 1C0 0.447715 0.447715 0 1 0H19C19.5523 0 20 0.447715 20 1Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -2,7 +2,7 @@
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import Badge from '../common/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'

View File

@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span

View File

@@ -64,7 +64,6 @@ onUnmounted(() => {
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
:tracks="tutorial.caption"
autoplay
class="w-full"
/>

View File

@@ -8,8 +8,8 @@ import {
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -76,14 +76,13 @@ const activeTutorial = () =>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
<MaskRevealButton
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
size="sm"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
@@ -99,7 +98,7 @@ const activeTutorial = () =>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</MaskRevealButton>
</div>
<ul class="flex flex-wrap gap-2">

View File

@@ -68,8 +68,7 @@ const plans: PricingPlan[] = [
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' },
{ text: 'pricing.plan.standard.feature3' }
{ text: 'pricing.plan.standard.feature2' }
]
},
{
@@ -123,11 +122,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p class="mt-3 text-base text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-3 text-base">
{{ t('pricing.subtitle', locale) }}
</p>
</div>
@@ -157,7 +156,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -173,18 +172,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Summary -->
<p class="px-6 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas px-6 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price -->
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas">
<span class="text-primary-comfy-canvas text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
@@ -193,7 +192,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Credits -->
<p
v-if="plan.creditsKey"
class="px-6 text-sm text-primary-comfy-canvas"
class="text-primary-comfy-canvas px-6 text-sm"
>
{{ t(plan.creditsKey, locale) }}
</p>
@@ -202,7 +201,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Estimate -->
<p
v-if="plan.estimateKey"
class="px-6 text-xs text-primary-comfy-canvas/80"
class="text-primary-comfy-canvas/80 px-6 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -212,10 +211,17 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li
v-for="feature in plan.features"
@@ -223,7 +229,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
class="flex items-start gap-2"
>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-sm text-primary-comfy-canvas">
<span class="text-primary-comfy-canvas text-sm">
{{ t(feature.text, locale) }}
</span>
</li>
@@ -263,7 +269,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -281,13 +287,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Enterprise heading -->
<h2
v-if="plan.isEnterprise"
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<!-- Summary -->
<p class="mt-2 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-2 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
@@ -295,25 +301,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<template v-if="plan.priceKey">
<div class="mt-6 flex items-baseline gap-1">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas/55">
<span class="text-primary-comfy-canvas/55 text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<p
v-if="plan.creditsKey"
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
>
{{ t(plan.creditsKey, locale) }}
</p>
<p
v-if="plan.estimateKey"
class="mt-2 text-xs text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-2 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -362,7 +368,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
>
<!-- Left side -->
<div
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
>
<div>
<span
@@ -371,11 +377,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
{{ t(enterprisePlan.labelKey, locale) }}
</span>
<h2
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<p class="mt-3 text-sm text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-3 text-sm">
{{ t(enterprisePlan.summaryKey, locale) }}
</p>
</div>
@@ -386,7 +392,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
{{ t('pricing.footnote', locale) }}
</p>
</section>

View File

@@ -54,11 +54,7 @@ const features: IncludedFeature[] = [
},
{
titleKey: 'pricing.included.feature11.title',
descriptionKey: 'pricing.included.feature11.description'
},
{
titleKey: 'pricing.included.feature12.title',
descriptionKey: 'pricing.included.feature12.description',
descriptionKey: 'pricing.included.feature11.description',
isComingSoon: true
}
]
@@ -69,10 +65,10 @@ const features: IncludedFeature[] = [
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
>
<h2
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
>
{{ t('pricing.included.heading', locale) }}
</h2>
@@ -85,7 +81,7 @@ const features: IncludedFeature[] = [
:key="feature.titleKey"
:class="
index < features.length - 1
? 'border-b border-solid border-primary-comfy-canvas/15'
? 'border-primary-comfy-canvas/15 border-b border-solid'
: ''
"
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
@@ -103,14 +99,14 @@ const features: IncludedFeature[] = [
v-else
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
/>
<p class="text-sm font-medium text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->
<p
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
v-html="t(feature.descriptionKey, locale)"
/>
</div>

View File

@@ -72,7 +72,6 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">

View File

@@ -1,40 +0,0 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { BadgeVariants } from '.'
import { badgeVariants } from '.'
const {
variant,
size,
class: className,
prependIcon,
appendIcon
} = defineProps<{
variant?: BadgeVariants['variant']
size?: BadgeVariants['size']
class?: string
prependIcon?: Component
appendIcon?: Component
}>()
</script>
<template>
<span
data-slot="badge"
:data-variant="variant"
:data-size="size"
:class="cn(badgeVariants({ variant, size }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />
</slot>
<span class="ppformula-text-center">
<slot />
</span>
<slot name="append">
<component :is="appendIcon" v-if="appendIcon" />
</slot>
</span>
</template>

View File

@@ -1,24 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
},
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
}
},
defaultVariants: {
size: 'md',
variant: 'default'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import { Primitive } from 'reka-ui'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonMaskVariants } from '.'
import {
BUTTON_MASK_LABEL_CLASS,
buttonMaskBadgeVariants,
buttonMaskVariants
} from '.'
interface Props extends PrimitiveProps {
variant?: ButtonMaskVariants['variant']
size?: ButtonMaskVariants['size']
iconPosition?: ButtonMaskVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
iconPosition,
hideLabel = true,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="button-mask"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(buttonMaskVariants({ variant, size, iconPosition }), className)"
>
<span
:data-icon-position="iconPosition ?? 'right'"
:data-hidden="hideLabel ? 'true' : 'false'"
:class="BUTTON_MASK_LABEL_CLASS"
>
<slot />
</span>
<span
:class="buttonMaskBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<ChevronRight class="size-4" :stroke-width="2" />
</slot>
</span>
</span>
</Primitive>
</template>

View File

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

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import { Primitive } from 'reka-ui'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonPillVariants } from '.'
import { buttonPillBadgeVariants, buttonPillVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonPillVariants['variant']
size?: ButtonPillVariants['size']
iconPosition?: ButtonPillVariants['iconPosition']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
iconPosition,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="button-pill"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(buttonPillVariants({ variant, size, iconPosition }), className)"
>
<span
:class="
cn(
'ppformula-text-center relative leading-none transition-all duration-500'
)
"
>
<slot />
</span>
<span
:class="buttonPillBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<ChevronRight class="size-4" :stroke-width="2" />
</slot>
</span>
</span>
</Primitive>
</template>

View File

@@ -1,102 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonPillVariants = cva({
base: 'group/button-pill isolate relative inline-flex w-fit uppercase cursor-pointer items-center overflow-hidden rounded-2xl p-1 text-sm font-bold tracking-wider text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
default: 'h-10 px-6 py-2.5 has-[>svg]:px-3',
lg: 'h-14 px-8 py-4 has-[>svg]:px-5'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'default',
iconPosition: 'right',
class:
'ps-6 pe-14 group-hover/pill-trigger:ps-14 group-hover/pill-trigger:pe-6 hover:ps-14 hover:pe-6'
},
{
size: 'lg',
iconPosition: 'right',
class:
'ps-8 pe-16 group-hover/pill-trigger:ps-16 group-hover/pill-trigger:pe-8 hover:ps-16 hover:pe-8'
},
{
size: 'default',
iconPosition: 'left',
class:
'ps-14 pe-6 group-hover/pill-trigger:ps-6 group-hover/pill-trigger:pe-14 hover:ps-6 hover:pe-14'
},
{
size: 'lg',
iconPosition: 'left',
class:
'ps-16 pe-8 group-hover/pill-trigger:ps-8 group-hover/pill-trigger:pe-16 hover:ps-8 hover:pe-16'
}
],
defaultVariants: {
variant: 'solid',
size: 'default',
iconPosition: 'right'
}
})
export const buttonPillBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-xl transition-all duration-500',
variants: {
variant: {
solid: 'text-primary-comfy-yellow bg-primary-comfy-ink',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
default: 'size-8',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'default',
iconPosition: 'right',
class:
'right-1 group-hover/button-pill:right-[calc(100%-36px)] group-hover/pill-trigger:right-[calc(100%-36px)]'
},
{
size: 'lg',
iconPosition: 'right',
class:
'right-1 group-hover/button-pill:right-[calc(100%-52px)] group-hover/pill-trigger:right-[calc(100%-52px)]'
},
{
size: 'default',
iconPosition: 'left',
class:
'left-1 group-hover/button-pill:left-[calc(100%-36px)] group-hover/pill-trigger:left-[calc(100%-36px)]'
},
{
size: 'lg',
iconPosition: 'left',
class:
'left-1 group-hover/button-pill:left-[calc(100%-52px)] group-hover/pill-trigger:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'default',
iconPosition: 'right'
}
})
export type ButtonPillVariants = VariantProps<typeof buttonPillVariants>

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { Component, HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
prependIcon?: Component
appendIcon?: Component
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled,
prependIcon,
appendIcon
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(buttonVariants({ variant, size }), className)"
>
<slot name="prepend">
<component :is="prependIcon" v-if="prependIcon" />
</slot>
<span class="ppformula-text-center">
<slot />
</span>
<slot name="append">
<component :is="appendIcon" v-if="appendIcon" />
</slot>
</Primitive>
</template>

View File

@@ -1,31 +0,0 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const buttonVariants = cva(
[
"focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 aria-invalid:bg-destructive aria-invalid:hover:bg-destructive/90 inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-2xl text-sm font-bold tracking-wider whitespace-nowrap transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
],
{
variants: {
size: {
default: 'h-10 px-6 py-2.5',
lg: 'h-14 px-8 py-4 text-base'
},
variant: {
default:
'bg-primary-comfy-yellow hover:bg-primary-comfy-yellow/90 text-primary-comfy-ink uppercase',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
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",
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'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -1,45 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuRootEmits, NavigationMenuRootProps } from 'reka-ui'
import { NavigationMenuRoot, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import NavigationMenuViewport from './NavigationMenuViewport.vue'
const {
viewport = true,
class: className,
...restProps
} = defineProps<
NavigationMenuRootProps & {
class?: HTMLAttributes['class']
viewport?: boolean
}
>()
const emits = defineEmits<NavigationMenuRootEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="
cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className
)
"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import type {
NavigationMenuContentEmits,
NavigationMenuContentProps
} from 'reka-ui'
import { NavigationMenuContent, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuContentProps & { class?: HTMLAttributes['class'] }
>()
const emits = defineEmits<NavigationMenuContentEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<NavigationMenuContent
data-slot="navigation-menu-content"
v-bind="forwarded"
:class="
cn(
'top-0 left-0 w-full px-8 py-6 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:border-primary-comfy-ink-light group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-3xl group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:text-primary-comfy-canvas group-data-[viewport=false]/navigation-menu:shadow-sm group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95',
className
)
"
>
<slot />
</NavigationMenuContent>
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuItemProps } from 'reka-ui'
import { NavigationMenuItem } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuItemProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<NavigationMenuItem
data-slot="navigation-menu-item"
v-bind="restProps"
:class="cn('relative', className)"
>
<slot />
</NavigationMenuItem>
</template>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuLinkEmits, NavigationMenuLinkProps } from 'reka-ui'
import { NavigationMenuLink, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuLinkProps & { class?: HTMLAttributes['class'] }
>()
const emits = defineEmits<NavigationMenuLinkEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<NavigationMenuLink
data-slot="navigation-menu-link"
v-bind="forwarded"
:class="
cn(
'data-active:text-primary-comfy-yellow focus:bg-transparency-white-t4 ring-primary-comfy-yellow outline-primary-comfy-yellow flex flex-col gap-1 rounded-xl p-2 text-sm transition-[color,box-shadow] hover:text-white focus:text-white focus-visible:ring-4 focus-visible:outline-1 data-active:bg-transparent data-active:hover:bg-transparent [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
className
)
"
>
<slot />
</NavigationMenuLink>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuListProps } from 'reka-ui'
import { NavigationMenuList, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuListProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<NavigationMenuList
data-slot="navigation-menu-list"
v-bind="forwardedProps"
:class="
cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className
)
"
>
<slot />
</NavigationMenuList>
</template>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
import { ChevronDown } from '@lucide/vue'
import type { NavigationMenuTriggerProps } from 'reka-ui'
import { NavigationMenuTrigger, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { navigationMenuTriggerStyle } from './navigationMenuTriggerStyle'
const {
class: className,
active,
...restProps
} = defineProps<
NavigationMenuTriggerProps & {
class?: HTMLAttributes['class']
active?: boolean
}
>()
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<NavigationMenuTrigger
data-slot="navigation-menu-trigger"
v-bind="forwardedProps"
:data-active="active ? '' : undefined"
:class="cn(navigationMenuTriggerStyle(), 'group', className)"
>
<span class="ppformula-text-center">
<slot />
</span>
<ChevronDown
class="relative ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuViewportProps } from 'reka-ui'
import { NavigationMenuViewport, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...restProps } = defineProps<
NavigationMenuViewportProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<div class="absolute top-full left-0 isolate z-50 flex justify-center">
<NavigationMenuViewport
data-slot="navigation-menu-viewport"
v-bind="forwardedProps"
:class="
cn(
'origin-top-center bg-primary-comfy-ink-light border-primary-comfy-ink-light relative left-(--reka-navigation-menu-viewport-left) mt-1.5 h-(--reka-navigation-menu-viewport-height) w-full overflow-hidden rounded-3xl border text-primary-comfy-canvas shadow-sm data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-(--reka-navigation-menu-viewport-width)',
className
)
"
/>
</div>
</template>

View File

@@ -1,10 +0,0 @@
import { cva } from 'class-variance-authority'
export const navigationMenuTriggerStyle = cva([
'group font-formula inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-2xl px-4 py-3 text-sm font-extrabold tracking-wider text-primary-comfy-canvas uppercase transition-[color,box-shadow] outline-none',
'hover:text-primary-warm-gray',
'data-[state=open]:hover:text-primary-comfy-yellow data-[state=open]:text-primary-comfy-yellow data-[state=open]:focus:text-primary-comfy-yellow',
'data-active:text-primary-comfy-yellow data-active:hover:text-primary-comfy-yellow',
'focus:bg-accent focus-visible:ring-primary-comfy-yellow focus:text-accent-foreground focus-visible:ring-3 focus-visible:outline-1',
'disabled:pointer-events-none disabled:opacity-50'
])

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose data-slot="sheet-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { X } from '@lucide/vue'
import { DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import SheetClose from './SheetClose.vue'
import SheetOverlay from './SheetOverlay.vue'
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
side?: 'top' | 'right' | 'bottom' | 'left'
closeLabel: string
}
defineOptions({
inheritAttrs: false
})
const {
side = 'right',
closeLabel,
class: classProp,
...delegatedProps
} = defineProps<SheetContentProps>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="
cn(
'fixed z-50 flex flex-col gap-4 bg-primary-comfy-ink transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'top' &&
'inset-x-0 top-0 h-auto data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 h-auto data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
classProp
)
"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<SheetClose
class="focus:ring-primary-comfy-yellow/50 text-primary-comfy-yellow border-primary-comfy-yellow absolute top-4 right-4 rounded-xl border p-2 ring-offset-primary-comfy-ink transition-opacity focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-6" />
<span class="sr-only">{{ closeLabel }}</span>
</SheetClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-primary-warm-gray text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="sheet-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
</div>
</template>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
DialogOverlayProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="
cn(
'bg-transparency-white-t4 fixed inset-0 z-50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
props.class
)
"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
DialogTitleProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-primary-warm-white font-semibold', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@@ -1,32 +0,0 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
export function useCurrentPath() {
const currentPath = ref('')
function update() {
currentPath.value = window.location.pathname
}
onMounted(() => {
update()
document.addEventListener('astro:page-load', update)
window.addEventListener('popstate', update)
})
onBeforeUnmount(() => {
document.removeEventListener('astro:page-load', update)
window.removeEventListener('popstate', update)
})
return currentPath
}
export function isHrefActive(href: string, currentPath: string): boolean {
if (!href || !currentPath || href.startsWith('http')) return false
const path = href.split('#')[0].split('?')[0]
if (!path) return false
function norm(s: string) {
return s.length > 1 ? s.replace(/\/$/, '') : s
}
return norm(path) === norm(currentPath)
}

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
windows: 'https://comfy.org/download/windows/nsis/x64',
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const

View File

@@ -62,12 +62,9 @@ export const externalLinks = {
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -1,4 +1,3 @@
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
@@ -8,7 +7,6 @@ export interface LearningTutorial {
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
@@ -30,14 +28,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
caption: [
{
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
@@ -48,15 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
caption: [
{
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -67,14 +49,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
caption: [
{
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -85,14 +59,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
caption: [
{
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -103,14 +69,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
caption: [
{
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -121,14 +79,6 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
caption: [
{
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1,193 +0,0 @@
import { externalLinks, getRoutes } from '../config/routes'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
export type NavColumnItem = {
label: string
href: string
badge?: 'new'
external?: boolean
}
export type NavColumn = {
header: string
items: NavColumnItem[]
}
export type NavFeatured = {
imageSrc: string
imageAlt?: string
title: string
cta: {
label: string
ariaLabel?: string
href: string
}
}
export type NavItem =
| {
label: string
columns: NavColumn[]
featured?: NavFeatured
href?: 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),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.tryWorkflow', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
}
},
columns: [
{
header: t('nav.products', locale),
items: [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{
label: t('nav.comfyApi', locale),
href: routes.api,
badge: 'new'
},
{
label: t('nav.comfyEnterprise', locale),
href: routes.cloudEnterprise
}
]
},
{
header: t('nav.colFeatures', locale),
items: [
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.docs', locale),
href: externalLinks.docs,
external: true
}
]
}
]
},
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),
title: t('nav.featuredCommunityTitle', locale),
cta: {
label: t('cta.watchDemo', locale),
ariaLabel: t('nav.featuredCommunityCtaAria', locale),
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/'
}
},
columns: [
{
header: t('nav.colPrograms', locale),
items: [
{ label: t('nav.comfyHub', locale), href: externalLinks.workflows },
{ label: t('nav.gallery', locale), href: routes.gallery },
{
label: t('nav.affiliates', locale),
href: routes.affiliates,
badge: 'new'
},
{
label: t('nav.learning', locale),
href: routes.learning,
badge: 'new'
}
]
},
{
header: t('nav.colConnect', locale),
items: [
{
label: t('nav.discord', locale),
href: externalLinks.discord,
external: true
},
{
label: t('nav.github', locale),
href: externalLinks.github,
external: true
},
{
label: t('nav.youtube', locale),
href: externalLinks.youtube,
external: true
},
{
label: t('nav.reddit', locale),
href: externalLinks.reddit,
external: true
},
{
label: t('nav.x', locale),
href: externalLinks.x,
external: true
},
{
label: t('nav.instagram', locale),
href: externalLinks.instagram,
external: true
}
]
}
]
},
{
label: t('nav.company', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/customer-story-card.jpg',
imageAlt: t('nav.featuredCompanyAlt', locale),
title: t('nav.featuredCompanyTitle', locale),
cta: {
label: t('cta.watchNow', locale),
ariaLabel: t('nav.featuredCompanyCtaAria', locale),
href: '/customers#hero-video'
}
},
columns: [
{
header: t('nav.company', locale),
items: [
{ label: t('nav.aboutUs', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.contact', locale), href: routes.contact }
]
},
{
header: t('nav.colMore', locale),
items: [
{
label: t('nav.customerStories', locale),
href: routes.customers
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,
external: true
}
]
}
]
}
]
}

View File

@@ -16,14 +16,6 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
},
'cta.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// HeroSection
'hero.title': {
@@ -1252,10 +1244,6 @@ const translations = {
en: 'Add more credits anytime',
'zh-CN': '可随时增加积分'
},
'pricing.plan.standard.feature3': {
en: 'Run 1 workflow concurrently (via API)',
'zh-CN': '通过 API 并发运行 1 个工作流'
},
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
'pricing.plan.creator.summary': {
@@ -1284,8 +1272,8 @@ const translations = {
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: 'Run up to 3 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 3 个工作流'
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
@@ -1312,8 +1300,8 @@ const translations = {
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: 'Run up to 5 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 5 个工作流'
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
@@ -1397,9 +1385,9 @@ const translations = {
'zh-CN': '随时加购积分'
},
'pricing.included.feature5.description': {
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
'zh-CN':
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
},
'pricing.included.feature6.title': {
en: 'Pre-installed models',
@@ -1445,19 +1433,10 @@ const translations = {
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA打造专属风格。'
},
'pricing.included.feature11.title': {
en: 'Run Workflows via API',
'zh-CN': '通过 API 运行工作流'
},
'pricing.included.feature11.description': {
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
'zh-CN':
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
},
'pricing.included.feature12.title': {
en: 'Parallel job execution',
'zh-CN': '并行任务执行'
},
'pricing.included.feature12.description': {
'pricing.included.feature11.description': {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
@@ -1851,69 +1830,10 @@ const translations = {
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
'nav.ctaDesktopCore': { en: 'DESKTOP', 'zh-CN': '桌面版' },
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
'nav.mobileMenuDescription': {
en: 'Site navigation and quick links',
'zh-CN': '网站导航和快速链接'
},
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
'nav.colMore': { en: 'More', 'zh-CN': '更多' },
// Dropdown items not yet covered above
'nav.reddit': { en: 'Reddit', 'zh-CN': 'Reddit' },
'nav.x': { en: 'X', 'zh-CN': 'X' },
'nav.instagram': { en: 'Instagram', 'zh-CN': 'Instagram' },
'nav.affiliates': { en: 'Affiliates', 'zh-CN': '联盟计划' },
'nav.contact': { en: 'Contact', 'zh-CN': '联系我们' },
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布Seedance 2.0'
},
'nav.featuredProductsAlt': {
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'nav.featuredCommunityAlt': {
en: 'Sky Replacement workflow demo image',
'zh-CN': '天空替换工作流演示图片'
},
'nav.featuredCommunityCtaAria': {
en: 'Watch the Sky Replacement demo',
'zh-CN': '观看天空替换演示'
},
'nav.featuredCompanyTitle': {
en: 'Customer story: Black Math',
'zh-CN': '客户故事Black Math'
},
'nav.featuredCompanyAlt': {
en: 'Black Math customer story image',
'zh-CN': 'Black Math 客户故事图片'
},
'nav.featuredCompanyCtaAria': {
en: 'Watch the Black Math customer story',
'zh-CN': '观看 Black Math 客户故事'
},
// SiteFooter
'footer.tagline': {

View File

@@ -4,7 +4,7 @@ import Analytics from '@vercel/analytics/astro'
import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -137,7 +137,7 @@ const websiteJsonLd = {
</noscript>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<SiteNav locale={locale} github-stars={githubStars} client:load />
<main class="mt-20 lg:mt-32">
<slot />
</main>

View File

@@ -1,30 +1,20 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import ProductCardsSection from "../components/home/ProductCardsSection.vue";
import HeroSection from "../components/home/HeroSection.vue";
import SocialProofBarSection from "../components/common/SocialProofBarSection.vue";
import ProductShowcaseSection from "../components/home/ProductShowcaseSection.vue";
import UseCaseSection from "../components/home/UseCaseSection.vue";
import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSection.vue";
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/home/ProductCardsSection.vue'
import HeroSection from '../components/home/HeroSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
import UseCaseSection from '../components/home/UseCaseSection.vue'
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
import GetStartedSection from '../components/home/GetStartedSection.vue'
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Comfy — Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
keywords={[
"comfyui app",
"comfyui web app",
"comfy ui application",
"comfyui application",
"comfy app",
"comfyui",
"visual ai app",
"node-based ai",
"generative ai workflows",
]}
description={t('hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
>
<HeroSection client:load />
<SocialProofBarSection />

View File

@@ -1,16 +1,6 @@
@import 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';
@import 'tw-animate-css';
/* PP Formula's native vertical metrics place the baseline too high, so caps
sit in the upper half of the line box. Overriding ascent/descent re-anchors
the baseline so caps render optically centered when line-height is tight.
BUT, IT IS NOT WELL SUPPORTED:
ascent-override: 92%;
descent-override: 8%;
line-gap-override: 0%; */
@font-face {
font-family: 'PP Formula';
src: url('/fonts/PPFormula-Light.woff2') format('woff2');
@@ -63,12 +53,10 @@
--color-site-dropdown: #332b38;
--color-primary-comfy-yellow: #f2ff59;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-ink-light: #2a2330;
--color-primary-comfy-canvas: #c2bfb9;
--color-primary-warm-white: #f0efed;
--color-primary-warm-gray: #7e7c78;
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
@@ -112,7 +100,6 @@
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
@@ -122,7 +109,6 @@
0% {
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
}
100% {
transform: translateX(0);
}
@@ -145,11 +131,9 @@
transform: scale(1);
opacity: 1;
}
85% {
opacity: 1;
}
100% {
transform: scale(1.75);
opacity: 0;
@@ -190,7 +174,6 @@
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}

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