Compare commits

...

54 Commits

Author SHA1 Message Date
bymyself
c7f297c24b chore: fix knip config hint and sync lockfile after rebase 2026-04-18 21:47:14 -07:00
bymyself
c6de167fe9 fix: address review nitpicks in test-recorder
- Guard box() against empty lines array (RangeError on Math.max)
- Sanitize testName in recording template to prevent injection
2026-04-18 21:45:45 -07:00
bymyself
6b02aab81a test: add unit tests for test-recorder transform and improve README
- Add 25 colocated unit tests for transform rules and engine
- Add tools/ to vitest include pattern
- Improve README with quick-start prereqs and test instructions
2026-04-18 21:45:45 -07:00
GitHub Action
7fd35c2f82 [automated] Apply ESLint and Oxfmt fixes 2026-04-18 21:45:45 -07:00
bymyself
0e5d788688 fix: add entry config for test-recorder in knip
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032682
2026-04-18 21:45:45 -07:00
bymyself
2d0fbe3822 fix: check both quote styles for Playwright import detection
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032677
2026-04-18 21:45:45 -07:00
bymyself
478b39cdc5 fix: remove misleading packageManager field from test-recorder
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032674
2026-04-18 21:45:45 -07:00
bymyself
61a6b402ae fix: remove dead wait() function from logger
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032673
2026-04-18 21:45:45 -07:00
bymyself
fb98dfd736 fix: remove blanket oxlint exclusion for tools/test-recorder
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032669
2026-04-18 21:45:45 -07:00
bymyself
cee63169ba fix: pin @playwright/mcp to 0.0.68 for reproducibility
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032667
2026-04-18 21:45:45 -07:00
bymyself
92561d2f5c fix: add _recording-session.spec.ts to .gitignore
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032664
2026-04-18 21:45:45 -07:00
bymyself
1f80ca56e5 fix: prevent replace-bare-page from matching inside comfyPage.page.
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3007032663
2026-04-18 21:45:45 -07:00
bymyself
3e3625bf95 fix: refactor codegen-transform skill to reference existing docs
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005775537
2026-04-18 21:45:45 -07:00
bymyself
a1aeb5254f fix: remove AI-Assisted Test Creation section from browser_tests/AGENTS.md
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005774857
2026-04-18 21:45:45 -07:00
GitHub Action
ef44acf16d [automated] Apply ESLint and Oxfmt fixes 2026-04-18 21:45:45 -07:00
bymyself
fded789d9f style: format .mcp.json and gh.ts with oxfmt 2026-04-18 21:45:45 -07:00
bymyself
2c01be33bd fix: cross-platform root detection and subprocess error handling
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550590
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550591
2026-04-18 21:45:45 -07:00
bymyself
f1a058ea4f fix: use path.basename for cross-platform path inference in transform
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550584
2026-04-18 21:45:45 -07:00
bymyself
b9a4f86713 fix: check pnpm install status and handle clipboard failure
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550581
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550583
2026-04-18 21:45:45 -07:00
bymyself
618c32416f fix: escape user input in generated test.describe string literals
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550593
2026-04-18 21:45:45 -07:00
bymyself
fd02ebcf26 fix: set non-zero exit code on check failure and remove unimplemented --version
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550585
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550586
2026-04-18 21:45:45 -07:00
bymyself
b2f96cc3c0 fix: escape single quotes in workflow name for template generation
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550592
2026-04-18 21:45:45 -07:00
bymyself
58135fed67 fix: use @playwright/mcp package for MCP server config
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550575
2026-04-18 21:45:45 -07:00
bymyself
2f56d6727d fix: document planner/generator/healer agent roles in AGENTS.md
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550576
2026-04-18 21:45:45 -07:00
bymyself
e5a967f3a7 fix: guard against missing agent files in patch script
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550578
2026-04-18 21:45:45 -07:00
bymyself
ff06060689 fix: require escalation criteria before test.fixme() in healer agent
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550573
2026-04-18 21:45:45 -07:00
bymyself
0d9171e8a3 fix: pin corepack pnpm version to match repo lockfile
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550580
2026-04-18 21:45:45 -07:00
bymyself
5e570c85a8 fix: add required file argument to transform usage example
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10694#discussion_r3005550579
2026-04-18 21:45:45 -07:00
bymyself
622f9a8238 feat: add Playwright test agents system — interactive recorder CLI, AI agents, and codegen transform
Three integrated systems for AI-assisted browser test creation:

1. comfy-test CLI (tools/test-recorder/)
   - Interactive 7-step recording flow for QA testers and non-developers
   - Environment checks with platform-specific install guidance
   - Codegen-to-convention transform engine
   - PR creation via gh CLI or manual GitHub web UI instructions
   - Commands: record, transform, check, list

2. Playwright AI agents (.claude/agents/)
   - Planner, generator, and healer agents patched with ComfyUI context
   - Regeneration scripts for Playwright updates (scripts/update-playwright-agents.sh)
   - MCP server config (.mcp.json) for agent browser interaction
   - Seed test and specs directory for agent-generated tests

3. Codegen transform skill (.claude/skills/codegen-transform/)
   - Transform rules: @playwright/test → comfyPageFixture, page → comfyPage,
     remove goto, canvas locators, waitForTimeout → nextFrame
   - Structural transforms: wrap in describe with tags, add afterEach cleanup
   - Fixture API reference and before/after examples
2026-04-18 21:45:45 -07:00
Christian Byrne
a3893a593d refactor: move select components from input/ to ui/ component library (#11378)
*PR Created by the Glary-Bot Agent*

---

## Summary

Reconciles `src/components/input/` (older select components) into
`src/components/ui/` (internal component library), eliminating the
separate `input/` directory entirely.

## Changes

- **Move MultiSelect** →
`src/components/ui/multi-select/MultiSelect.vue`
- **Move SingleSelect** →
`src/components/ui/single-select/SingleSelect.vue`
- **Extract shared resources** → `src/components/ui/select/types.ts`
(SelectOption type) and `src/components/ui/select/select.variants.ts`
(CVA styling variants)
- **Update 7 consuming files** to use new import paths
- **Update 1 test file** (AssetFilterBar.test.ts mock paths)
- **Move stories and tests** alongside their components
- **Delete `src/components/input/`** directory

## Context

The `input/` directory contained only MultiSelect and SingleSelect — two
well-built components that already used the same stack as `ui/` (Reka
UI, CVA, Tailwind 4, Composition API). MultiSelect even imported
`ui/button/Button.vue`. Moving them into `ui/` removes the split and
consolidates all reusable components in one place.

No API changes — all component props, slots, events, and behavior are
preserved exactly.

## Verification

- `pnpm typecheck` 
- `pnpm build` 
- `pnpm lint` (stylelint + oxlint + eslint) 
- All 15 relevant tests pass (MultiSelect: 5, SingleSelect: 2,
AssetFilterBar: 8) 
- `pnpm knip` — no dead exports 
- No stale `@/components/input/` references remain 
- Pre-commit hooks pass 
- Git detected all moves as renames (97-100% similarity)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11378-refactor-move-select-components-from-input-to-ui-component-library-3476d73d3650810e99b4c3e0842e67f3)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 20:00:34 -07:00
Terry Jia
deba72e7a0 gizmo controls (#11274)
## Summary
Add Gizmo transform controls to load3d

- Remove automatic model normalization (scale + center) on load; models
now appear at their original transform. The previous auto-normalization
conflicted with gizmo controls — applying scale/position on load made it
impossible to track and reset the user's intentional transform edits vs.
the system's normalization
- Add a manual Fit to Viewer button that performs the same normalization
on demand, giving users explicit control
- Add Gizmo Controls (translate/rotate) for interactive model
manipulation with full state persistence across node properties, viewer
dialog, and model reloads
- Gizmo transform state is excluded from scene capture and recording to
keep outputs clean

## Motivation
The gizmo system is a prerequisite for these potential features:
- Custom cameras — user-placed cameras in the scene need transform
gizmos for precise positioning and orientation
- Custom lights — scene lighting setup requires the ability to
interactively position and aim light sources
- Multi-object scene composition — positioning multiple models relative
to each other requires per-object transform controls
- Pose editor — skeletal pose editing depends on the same transform
infrastructure to manipulate individual bones/joints

Auto-normalization was removed because it silently mutated model
transforms on load, making it impossible to distinguish between the
original model pose and user edits. This broke gizmo reset (which needs
to know the "clean" state) and would corrupt round-trip transform
persistence.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558)
by [Unito](https://www.unito.io)
2026-04-18 22:45:06 -04:00
Rizumu Ayaka
3db0eac353 perf: textarea widget layer composition (#10804)
## Summary

I noticed that nodes using textarea for user input, which contain long
user-entered text, require scrolling within a single node.

Having 40 such textarea nodes in a test canvas is enough to cause lag
(20fps). In contrast, a control group using regular nodes can handle up
to 500 nodes without lag (60fps).

the numerous scrolling text widgets in test workflows are the main
source of performance pressure. Each scrolling text input box imposes
independent layout and layering pressure.

I initially tried more complex solutions to fix this issue, like virtual
scrolling. However, I found that a simple CSS modification was
sufficient and effective. Even when I quadrupled the problematic number
of nodes on my M5 MacBook Air, it remained smooth.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10804-perf-textarea-widget-layer-composition-3356d73d3650814da75adec266d7cad9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-19 02:29:00 +00:00
Dante
4c7729ee0b fix: remove hover dimming overlay on image nodes (#11296)
## Summary

Remove the black opacity/dimming overlay on image node hover and add
shadows to action buttons for visibility against light backgrounds.

## Changes

- **What**: Remove `opacity-50` dimming on hover in
`DisplayCarousel.vue`, remove `transition-opacity hover:opacity-80` from
grid thumbnails in `ImagePreview.vue`, add `shadow-md` to action buttons
in `ImagePreview.vue`. Applies to Save Image, Load Image, Preview Image,
and all nodes using these shared image components.

## Review Focus

Button shadows (`shadow-md`) should provide sufficient contrast against
light image backgrounds without needing the dimming overlay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11296-fix-remove-hover-dimming-overlay-on-image-nodes-3446d73d36508193bb5cc27d431014fd)
by [Unito](https://www.unito.io)
2026-04-18 22:40:11 +00:00
Dante
40083d593b test: cover Button, Textarea, Slider components (#11325)
Closes coverage gaps in \`src/components/ui/\` as part of the unit-test
backfill. Uses \`@testing-library/vue\` +
\`@testing-library/user-event\` for user-centric, behavioral assertions.

## Testing focus

Three Reka-UI primitives. The challenge is testing the contract — not
the library internals — given happy-dom's gaps and Reka's
\`useMounted()\`-based async initialization.

### \`Button\` (7 tests)

- Slot rendering + click event propagation.
- \`loading=true\`: three invariants hold **simultaneously** — slot
hidden, \`pi-spin\` spinner present, button is \`toBeDisabled()\`.
- \`disabled=true\` alone: button disabled, no spinner.
- \`as="a"\`: polymorphic root tag (Reka \`Primitive\`'s \`as\` prop
switches the rendered element).
- Variant class pass-through: **one** deliberate style assertion because
the variant-system wiring is part of the component's public contract. No
other styling/class checks (AGENTS.md bans class-based tests).

### \`Textarea\` (6 tests)

- \`v-model\` two-way binding: \`user.type()\` updates the bound ref;
initial value populates the textarea.
- \`disabled\` asserted **behaviorally** — typing is blocked when
disabled, not just the attribute presence.
- Pass-through: \`placeholder\`, \`rows\`, \`class\`.

### \`Slider\` (8 tests)

- Thumb count matches \`modelValue.length\` (range support).
- ARIA: \`aria-valuemin\` / \`aria-valuemax\` / \`aria-valuenow\`.
**Caveat:** Reka's \`SliderRoot\` uses \`useMounted()\`, so
\`aria-valuenow\` is absent on the first render tick. The tests use a
two-tick \`flush()\` helper (\`await nextTick()\` twice) to wait it out
— no mocking of Reka required.
- Keyboard drag: \`user.keyboard('{ArrowRight}')\` / \`'{ArrowLeft}'\`
moves the value; with \`step: 10\` starting from 50, ArrowRight produces
exactly \`[60]\`.
- \`disabled\` → no emit on keyboard events.

### Reka integration limit

Pointer-driven \`slide-start\` / \`slide-end\` gestures in happy-dom
would require faking \`getBoundingClientRect\` and \`setPointerCapture\`
— that crosses into mocking Reka internals. Keyboard-drag paths are
covered instead (the user-facing contract); the \`pressed\` CSS state is
exercised implicitly by surviving a full mount + update cycle.

## Principles applied

- No mocks of Vue, Reka, or \`@vueuse/core\`.
- Queries via \`getByRole\` / \`getByLabelText\`; **no** class-name or
Tailwind-token queries (per AGENTS.md).
- All 21 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-18 22:36:16 +00:00
Dante
7089a7d1a0 fix: show asset display names in bulk delete confirmation (#11321)
## Summary
Bulk-delete confirmation on Comfy Cloud listed raw SHA-256 filenames,
making the modal impossible to use to verify what would be deleted.

## Changes
- **What**: `useMediaAssetActions.deleteAssets` now maps each asset
through `getAssetDisplayName`, so the confirmation's `itemList` matches
the user-assigned names shown in the left media panel
(`MediaAssetCard`).
- **Tests**: Added two regression tests covering `user_metadata.name` /
`display_name` resolution and the `asset.name` fallback.

## Review Focus
- Parity with `MediaAssetCard` display: we reuse the same
`getAssetDisplayName` helper; extension stripping (via
`getFilenameDetails`) is not applied in the modal since file extensions
are useful context when confirming deletions.

Reported in Slack:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776383570015289

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11321-fix-show-asset-display-names-in-bulk-delete-confirmation-3456d73d36508108a3d5f2290ca39e18)
by [Unito](https://www.unito.io)
2026-04-18 22:35:39 +00:00
Christian Byrne
3b4811b00d feat: deploy E2E coverage HTML report to GitHub Pages (#11291)
## Summary

Browsable E2E coverage report deployed to GitHub Pages on every main
merge, replacing the current workflow of downloading LCOV artifacts and
using an external viewer.

## Changes

- **What**: After merging shard LCOVs, run `genhtml` to produce an HTML
report with per-file line coverage. On `main`, deploy to GitHub Pages
via `actions/deploy-pages`. For PR runs, the HTML report is still
available as the `e2e-coverage-html` artifact.
- **Dependencies**: None new — `genhtml` is part of the `lcov` package
already installed in the workflow.

## Review Focus

- **GitHub Pages must be enabled**: Settings → Pages → Source → "GitHub
Actions". Without this the deploy job will fail silently.
- The deploy job only runs for `main` branch (`if:
github.event.workflow_run.head_branch == 'main'`) so PR coverage doesn't
clobber the deployed report.
- Added `pages: write` and `id-token: write` permissions to the workflow
for the Pages deployment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11291-feat-deploy-E2E-coverage-HTML-report-to-GitHub-Pages-3446d73d36508136ba6fd806690c9cfc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-18 15:40:59 -07:00
jaeone94
b756545f59 refactor: clean up ChangeTracker logging, guards, and redundant widget wrapper (#11328)
## Summary

Follow-ups to PR #10816. Bundles four review items left open after that
PR merged — three inside `ChangeTracker` itself and one in the widget
composable that wraps it.

### What changed

- **Removed all `loglevel` logging from `src/scripts/changeTracker.ts`**
— the logger was set to `info`, so every `logger.debug` call was dead
code at runtime. `logger.warn` calls were replaced with direct
reporting. The only-downstream dead code (`graphDiff` helper) and its
sole dependency (`jsondiffpatch`) are also removed.
- **Named the `captureCanvasState()` guard conditions** —
`isUndoRedoing` and `isInsideChangeTransaction` now carry the intent
that the inline `_restoringState` / `changeCount > 0` expressions used
to obscure.
- **Surfaced lifecycle violations through a single reporting helper** —
`reportInactiveTrackerCall()` logs `console.warn` once per method per
session and, on Desktop, emits a `Sentry.addBreadcrumb` with the
offending workflow path. `deactivate()` and `captureCanvasState()` share
this path so the same invariant is reported consistently.
- **Inlined `captureWorkflowState` wrapper in `useWidgetSelectActions`**
— the private helper forwarded to `changeTracker.captureCanvasState()`
with no added logic. Both call sites now invoke the change tracker
directly.

### Issues fixed

- Fixes #11249
- Fixes #11259
- Fixes #11258
- Fixes #11248

### Test plan

- [x] `pnpm test:unit src/scripts/changeTracker.test.ts` — 16 tests pass
- [x] `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.test.ts`
— 6 tests pass
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`
2026-04-18 22:28:05 +00:00
Alexander Brown
da91bdc957 fix: persist middle-click reroute node setting across reloads (#11362)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Remove hardcoded `LiteGraph.middle_click_slot_add_default_node = true`
from `slotDefaults` extension `init()` that unconditionally overrode the
user's persisted preference on every page load
- Add E2E regression test verifying both the setting store value and the
LiteGraph runtime flag persist through page reload

## Root Cause

The `Comfy.SlotDefaults` extension's `init()` method (in
`slotDefaults.ts`) contained a hardcoded
`LiteGraph.middle_click_slot_add_default_node = true` from the original
JS→TS conversion (July 2024). When `Comfy.Node.MiddleClickRerouteNode`
was later made configurable in v1.3.42, this line was never removed.
Since extension `init()` runs **after** `useLitegraphSettings()` syncs
the stored value, the hardcoded assignment overwrote the user's
preference on every reload.

## Changes

| File | Change |
|------|--------|
| `src/extensions/core/slotDefaults.ts` | Remove line 21
(`LiteGraph.middle_click_slot_add_default_node = true`) |
| `browser_tests/tests/dialogs/settingsDialog.spec.ts` | Add reload
persistence test asserting both store value and LiteGraph global |

The setting default (`true`) is already properly managed by
`coreSettings.ts` and reactively synced via `useLitegraphSettings.ts`,
so removing the hardcoded line preserves existing default behavior while
allowing user overrides to persist.

## Screenshots

![Setting shown as enabled (default
state)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970358-dcd6bd51-00c8-4ed4-86ce-0f1a89576f52.png)

![Setting toggled off by
user](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970719-fb1f587f-964d-4e6c-954e-3145812badaf.png)

![Setting correctly persists as off after page reload (with fix
applied)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528971113-36b577cb-5fd1-445d-8c8f-3ea8f6f46326.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11362-fix-persist-middle-click-reroute-node-setting-across-reloads-3466d73d365081ef8692dbd0619c8594)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 21:29:44 +00:00
Christian Byrne
cf3006f82c fix: reduce noise in coverage Slack notifications (#11283)
## Summary

Suppress low-signal coverage Slack notifications that show +0.0% or
-0.0% deltas.

## Changes

- **What**: Add `MIN_DELTA` threshold (0.05%) so only meaningful
improvements trigger notifications. Only display rows for metrics that
actually improved (no more E2E row showing -0.0% alongside a real unit
improvement). Fix `formatDelta` to clamp near-zero values to `+0.0%`
instead of showing `-0.0%`.
- 4 of the first 6 notifications posted were noise (+0.0% deltas from
instrumentation jitter). With this change, only 2 of 6 would have been
posted — both showing real improvements.

## Review Focus

The `MIN_DELTA` value of 0.05 means any delta that rounds to ±0.0% at 1
decimal place is suppressed. This matches the display precision so users
never see +0.0% notifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11283-fix-reduce-noise-in-coverage-Slack-notifications-3436d73d3650819ab3bcfebdb748ac8b)
by [Unito](https://www.unito.io)
2026-04-18 13:28:32 -07:00
pythongosssss
be2d757c47 test: add regression test for getCanvasCenter null guard (#8399) (#11271)
## Summary

Add a regression test for #8399 (null check in `getCanvasCenter` to
prevent crash on asset insert). The fix in
`src/services/litegraphService.ts` added optional chaining around
`app.canvas?.ds?.visible_area` with a `[0, 0]` fallback so inserting an
asset before the canvas finishes initializing no longer crashes. There
was no existing unit test for `litegraphService`, so this regression
could silently return.

## Changes

- **What**: New unit test file `src/services/litegraphService.test.ts`
covering `useLitegraphService().getCanvasCenter`.
- Mocks `@/scripts/app` so `app.canvas` can be swapped per test via
`Reflect.set`.
- Null-canvas case (regression for #8399): returns `[0, 0]` instead of
throwing.
- Missing `ds.visible_area` case: also returns `[0, 0]`.
- Initialised case: returns the centre of the visible area.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11271-test-add-regression-test-for-getCanvasCenter-null-guard-8399-3436d73d3650815c9925c8fdf9ec4bd3)
by [Unito](https://www.unito.io)
2026-04-18 16:32:03 +00:00
Terry Jia
54f3127658 test: regenerate screenshot expectations (#11360)
## Summary
regenerate screenshot expectations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11360-test-regenerate-screenshot-expectations-3466d73d365081878addd53a266a31b7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-18 09:10:02 -04:00
Kelly Yang
6dba67da6b refactor: remove @ts-expect-error suppressions in sidebar components (#11338)
…(issue #11092 phase 4a)

## Summary
Part of #11092 — Phase 4a: remove 10 @ts-expect-error suppressions from
three sidebar component files.
## Changes
3 files in the sidebar had `@ts-expect-error` suppressions that all
traced back to the same root cause: **optional properties on generic
interfaces that TypeScript cannot narrow through indirect conditions.**

`TreeExplorerNode<T>` declares `data?: T` — optional by design, since
folder nodes may carry no payload. Every `handleClick`, `handleDrop`,
and `handleDelete` method that accessed `this.data` was relying on the
runtime invariant that leaf nodes always have data, but TypeScript has
no way to derive `data !== undefined` from `this.leaf === true`. The fix
was to make the invariant explicit in the condition (`if (this.leaf &&
this.data)`) or add an early-return guard (`if (!nodeDefToAdd) return`).

The same pattern appeared in a closure in `ModelLibrarySidebarTab.vue`:
`model` was `ComfyModelDef | null` from an outer const, and `if
(this.leaf)` inside a method cannot narrow a captured variable. Widening
the condition to `if (this.leaf && model)` resolved it. Two additional
suppressions in that file covered `addNodeOnGraph`'s nullable return and
its optional `widgets` property, both fixed with optional chaining.

The remaining suppression was an unannotated function parameter inferred
as `any`; adding the explicit type from the `filters` ref removed it.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are TypeScript-safety refactors (extra
null/undefined guards) plus new unit tests; runtime behavior should only
differ in edge cases where `data`/`model`/`widgets` are unexpectedly
missing.
> 
> **Overview**
> Removes several `@ts-expect-error` suppressions in sidebar library
tabs by making leaf-node invariants explicit (`if (this.leaf &&
this.data/model)`), adding early returns for missing drag-drop payloads,
and using optional chaining for nullable `addNodeOnGraph`/`widgets`
access.
> 
> Adds new Vitest coverage for `ModelLibrarySidebarTab`,
`NodeLibrarySidebarTab`, and `NodeBookmarkTreeExplorer` to validate
click-to-add-node behavior, folder expansion toggling, filter add/remove
flow, bookmark drag/drop, and safe no-op paths when required data is
absent.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
acd2855151. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11338-refactor-remove-ts-expect-error-suppressions-in-sidebar-components-3456d73d365081e2858af020b88d7f05)
by [Unito](https://www.unito.io)
2026-04-17 20:28:25 -07:00
Johnpaul Chiwetelu
beaa269a63 feat: polish settings dialog layout and keybinding display (#11241)
Polish keybinding display. Based on #11212 with adjustments:
left-aligned content (no centering), key uppercase moved to UI layer.

- Reduce settings content font size to 14px
- Increase spacing between setting sections with cleaner dividers
- Consistent min-height for form items (toggle, slider, dropdown)
- Capitalize keybinding badges via CSS `uppercase` instead of mutating
data model
- Remove '+' separator between keybinding badges
- Unbold keybinding badges with fixed min-width

Supersedes #11212

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11241-feat-polish-settings-dialog-layout-and-keybinding-display-3426d73d3650812a97e4d941a76a4fe9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alex <alex@Mac.localdomain>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-17 20:22:39 -07:00
Kelly Yang
cf98013c18 test: expand Image Crop E2E and fix loading overlay deadlock (#11193)
## Summary

Expands Playwright coverage for the **ImageCropV2** widget (Levels 1–3
from the image crop E2E plan), fixes **loading / image mount** behavior
when `imageUrl` changes, adds **stable resize-handle selectors**, and
adds a **small Vitest** for URL→loading transitions.

## Changes

- [x] **Level 1 (E2E)** — Empty state: assert resize handle hidden;
screenshot baseline `image-crop-empty-state.png`; pointer drag on empty
state does not change widget bounds.
- [x] **Level 1 (E2E)** — After run: assert **8 visible** resize handles
with `data-testid` + `filter({ visible: true })`; broken `img.src`
returns to empty state (`crop-empty-state`, no overlay).
- [x] **Level 1 (E2E)** — **Slow `/api/view`** route (delay only
`example.png`) to assert **“Loading…”** then hidden after image loads;
comment clarifies delay is in the route handler, not
`page.waitForTimeout`.
- [x] **Level 2 (E2E)** — Drag clamps to **right/bottom** and
**top-left** image bounds via `setCropBounds` + `expect.poll` on natural
bounds.
- [x] **Level 3 (E2E)** — Free resize: right / left / bottom / top
edges; SE and NW corners; `MIN_CROP_SIZE` (16px); right-edge boundary
clamp; **8 handles** screenshot `image-crop-eight-handles.png`; SE/NW
screenshots (`image-crop-resize-se.png`, `image-crop-resize-nw.png`).
- [x] **E2E helpers** — Shared `getCropValue`, `setCropBounds`,
`dragOnLocator`, `POINTER_OPTS`; drag regression uses **`expect.poll`**
instead of `toPass` where appropriate.
- [x] **`WidgetImageCrop.vue`** — When `imageUrl` is set, **always
render `<img>`**; show loading as an **absolute overlay** (fixes
deadlock where `isLoading` blocked `<img>` so `@load` never ran); add
**`data-testid="crop-resize-{direction}"`** on resize handles.
- [x] **`useImageCrop.ts`** — Watch `imageUrl` and drive `isLoading`;
extract **`imageCropLoadingAfterUrlChange`** (`boolean | null`) for
clear semantics and tests.
- [x] **`useImageCrop.test.ts`** — Vitest coverage for
`imageCropLoadingAfterUrlChange` (null URL, URL change, first URL,
unchanged URL).

## Screenshot / CI notes

- [ ] **Linux screenshot expectations** for new/updated
`toHaveScreenshot(...)` names must be produced on **CI (Linux)** — add
the **`New Browser Test Expectation`** label (or equivalent workflow);
**do not** commit local **Darwin** golden files.
- [x] Existing Linux baselines under `imageCrop.spec.ts-snapshots/` for
prior tests are unchanged where applicable; new baselines are expected
from CI after merge workflow.

## Files

- [x] `browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts`
- [x] `src/components/imagecrop/WidgetImageCrop.vue`
- [x] `src/composables/useImageCrop.ts`
- [x] `src/composables/useImageCrop.test.ts` (new)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches interactive crop UI rendering and `isLoading` state
transitions, which can affect user-visible behavior and input handling;
changes are mitigated by extensive new E2E and unit tests.
> 
> **Overview**
> Improves the `WidgetImageCrop` loading behavior by always rendering
the preview `<img>` when `imageUrl` is set and showing “Loading…” as an
absolute overlay, preventing a deadlock where `isLoading` could block
the `@load` event. Adds stable `data-testid="crop-resize-{direction}"`
selectors for resize handles and hardens pointer-capture handling in
`useImageCrop`.
> 
> Greatly expands automated coverage: the Playwright spec now tests
empty-state rendering/screenshot, drag/resize interactions (edge/corner,
min size, and clamping to image bounds), aspect-ratio lock handle
visibility, slow `/api/view` loading overlay behavior, and broken image
fetch recovery. Adds a new Vitest suite for `useImageCrop` (including
`imageCropLoadingAfterUrlChange`) to unit-test URL→loading transitions
and core drag/resize/aspect-ratio logic.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c4f88a42b5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11193-test-expand-Image-Crop-E2E-and-fix-loading-overlay-deadlock-3416d73d365081eb99dae577c939baa9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-17 20:22:05 -07:00
Benjamin Lu
ecb7fd4796 feat: add frontend subscription success recovery (#11286)
Improving our subscription detection system. Optimal will have to come
after BE team brings personal billing to cloud repo off of comfy api.

## Summary
- replace the dialog-local focus poller with a frontend checkout tracker
stored in `localStorage`
- recover pending subscription checkouts from app boot plus global page
lifecycle (`pageshow`, `visibilitychange`) with bounded retries only
while an attempt is pending
- emit `subscription_success` through GTM with frontend-derived metadata
once subscription state reaches the expected target tier/cycle

## Why
This is the frontend-only 80/20 path. It fixes the brittle "old tab must
regain focus" behavior without adding new backend endpoints or backend
event storage. The browser records one pending checkout attempt when
checkout is opened, and any returning cloud tab can recover it later by
comparing current subscription state against the expected target plan.

## Tradeoffs
- browser-scoped, not backend-authoritative
- no server transaction id
- scheduled downgrades through the billing portal are intentionally not
inferred as immediate success events
- still best-effort compared with the backend outbox/WebSocket approach

## Validation
- `pnpm exec vitest run
src/platform/cloud/subscription/composables/useSubscription.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts`
- `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11286-feat-add-frontend-subscription-success-recovery-3436d73d3650814d9f74c89e6926aa84)
by [Unito](https://www.unito.io)
2026-04-17 22:49:49 +00:00
AustinMroz
e28c1e7e43 Show multitype slices of shared color (#11250)
A tiny update requested by the backend team.

Previously, multitype slot indicators would have inputs that resolve to
the same connection color display be combined into a single slice. For
example, both `INT` and `FLOAT` have the same color, so an `INT,FLOAT`
slot displays as a solid color instead of 2 semi-circles. This was a
conscientious decision to improve readability on slots that allow many
types, but meant that the more common cases (like `INT,FLOAT`) would
have no indicator at all. Since priority is given to types based on
order of listing, node authors can still control which types are elided
on a slot accepting many types.

<img width="430" height="320" alt="image"
src="https://github.com/user-attachments/assets/1fc7fb1c-a634-487c-bc03-711637aeef13"
/>

- I do not believe there are any core nodes affected by this change.
- The vue implementation of merging slot colors never functioned
properly, but is still removed.
- Vue was bugged to incorrectly pass slot types for widgets. This is
also fixed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11250-Show-multitype-slices-of-shared-color-3436d73d365081b6b484ea74423435a1)
by [Unito](https://www.unito.io)
2026-04-17 22:19:59 +00:00
Yourz
39dc8d896b feat(website): unified preview — cloud page, API & enterprise pages, use case images (#11273)
## Summary

Unified preview branch combining three feature PRs for the website
product pages.

> **Constituent PRs:** #11247, #11270, #11266

## Changes

- **Cloud page** (#11247): Add Cloud product page sections (Hero,
Reason, FAQ, AI Models, Audience, Pricing, ProductCards). Extract
`FAQSection` to `common/` and `ReasonSection` to `product/shared/` for
reuse across product pages. Add cloud-related i18n translations.
- **API & Enterprise pages** (#11270): Add API page (Hero, Steps,
Automation, Reason) and Enterprise page (Hero, Team, DataOwnership,
BYOKey, Orchestration, Reason). Add shared `CardGridSection`,
`FeatureShowcaseSection`, `CloudBannerSection`. Add all API/enterprise
i18n translations.
- **Use case images** (#11266): Replace placeholder divs with real
images in `UseCaseSection`. Add SVG blob clip-paths
(`objectBoundingBox`) and crossfade transitions on category switch. Use
`useId()` for unique clip-path IDs.

## Review Focus

- Shared component API design (`ReasonSection` slot/prop surface)
- Component placement: `common/` vs `product/shared/`
- Clip-path coordinate accuracy and crossfade transition smoothness

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-04-17 22:17:49 +00:00
Kelly Yang
f6f267b46d test: add unit tests for slotCalculations (#11302)
# PR Description

#11106 
**This PR only focus on `slotCalculations.ts`.**

Add unit tests for `slotCalculations.ts` — the centralized utility that
calculates input/output slot positions in graph coordinates. This file
had zero test coverage despite containing non-trivial branching logic
used by both the litegraph adapter and the Vue renderer layout system.

## What's covered

### `calculateInputSlotPosFromSlot`
- [x] **Collapsed nodes**: Returns the node origin shifted up by half
the title height.
- [x] **Hard-coded offsets**: Slots with specific `pos` offsets return
`nodeOrigin + pos` directly.
- [x] **Default vertical layout**: Covers first slot x/y, multi-slot
vertical ordering, `slotStartY` offset, exclusion of widget input slots,
and exclusion of fixed-position slots from index ordering.

### `getSlotPosition` (Legacy fallback path, `vueNodesMode` disabled)
- [x] **Coordinate derivation**: Input and output slot positions derived
correctly from `node.pos`.
- [x] **Collapsed state**: Collapsed input and output nodes use
`title-height` and `NODE_COLLAPSED_WIDTH` offsets.
- [x] **Boundary handling**: Out-of-range slot index falls back to node
origin.



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only changes that don’t affect runtime behavior; risk is limited
to potential brittleness if slot layout constants or expectations
change.
> 
> **Overview**
> Adds a new `slotCalculations.test.ts` suite covering
`calculateInputSlotPosFromSlot` and the legacy (`LiteGraph.vueNodesMode`
disabled) path of `getSlotPosition`.
> 
> Tests exercise key branches for collapsed nodes, hard-coded slot `pos`
overrides, default vertical slot ordering (including `slotStartY`), and
filtering of widget/fixed-position inputs, plus boundary behavior for
out-of-range slot indices.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
51c5318695. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11302-test-add-unit-tests-for-slotCalculations-3446d73d36508181a0ade81be05bd25f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-17 21:45:26 +00:00
Dante
e8d833bc54 test: cover useLazyPagination, useRangeEditor, useCurveEditor (#11326)
Closes coverage gaps in \`src/composables/\` as part of the unit-test
backfill.

## Testing focus

Three composables, each a different kind of test challenge: reactive
pagination state, DOM-track drag math, and SVG pointer interaction. No
third-party library is mocked.

### \`useLazyPagination\` (10 tests)

- Accepts both \`Ref<T[]>\` and plain \`T[]\` inputs.
- \`currentPage\` ceiling at \`totalPages\` (clamp behavior).
- Source-array replacement resets internal page state.
- \`loadedPages\` (Set) accumulates across navigation.
- **Observed source issue.** \`loadNextPage\` is declared \`async\` but
contains no \`await\` (the artificial delay is commented out).
Consequence: \`isLoading\` is never externally observable as \`true\`,
and the concurrent-call dedup in the design doesn't actually fire in
practice. Tests cover **observable** behavior only; the finding is noted
here as a candidate follow-up fix.

### \`useRangeEditor\` (11 tests)

- Drags each of \`min\` / \`max\` / \`midpoint\` handles; respects the
\`showMidpoint\` toggle (events on the midpoint are ignored when
hidden).
- Value clamping within \`[valueMin, valueMax]\`.
- \`denormalize\` receives the correct normalized position — verifies
the 0–1 mapping math, not just that it was called.
- \`trackRef.value === null\` → pointer events are no-ops (null-safety).
- **Real lifecycle.** Mounts a tiny \`defineComponent\` via
\`@testing-library/vue\`'s \`render\` and exercises cleanup through
\`unmount()\`. \`onBeforeUnmount\` only fires inside a component
instance — \`effectScope.stop()\` alone is insufficient.

### \`useCurveEditor\` (14 tests)

- \`curvePath\` empty when fewer than 2 points.
- Linear interpolation: \`M\` + \`L\` command sequence, points sorted by
x before drawing.
- Non-linear uses \`createInterpolator\` (our module → OK to mock and
assert call shape).
- Drag: dispatching \`pointermove\` updates \`modelValue\`; after
\`pointerup\`, a follow-up \`pointermove\` is a no-op.
- **happy-dom gaps polyfilled.** \`Element.setPointerCapture\` is
stubbed per-element and \`DOMPoint.prototype.matrixTransform\` is added
in \`beforeEach\`. Since the SVG has no CTM, \`DOMMatrix.inverse()\`
returns identity — so \`svgCoords\` maps \`clientX\`/\`clientY\`
directly into curve space, giving deterministic assertions without
brittle coordinate math.

## Principles applied

- No mocks of \`vue\`, \`@vueuse/core\`, or \`es-toolkit\`.
- Behavioral assertions only — no return-shape checks.
- All 35 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-17 21:41:09 +00:00
AustinMroz
3fd3c565ae Fix dropdown chevron color (#11335)
Updates the the color of the chevron on dropdown widgets to only have
the disabled color when the widget is disabled.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/25d35e78-9147-4397-be19-df9d6f87ac72"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/3bf3640d-fa14-42cb-afb4-7109eb878d1a"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11335-Fix-dropdown-chevron-color-3456d73d3650819e99c7d15173f11319)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-17 20:37:22 +00:00
Jedrzej Kosinski
ff4c812d08 feat: show sign-in button via server feature flag (#11298)
## Summary

Show the sign-in button in the frontend when the `show_signin_button`
server feature flag is set, without requiring a special desktop
distribution build.

## Changes

- Add `SHOW_SIGNIN_BUTTON` to `ServerFeatureFlag` enum
- Add `showSignInButton` getter in `useFeatureFlags` composable (returns
`boolean | undefined`)
- Update `WorkflowTabs.vue` to use `flags.showSignInButton ?? isDesktop`
- the server flag takes precedence when set, falls back to compile-time
`isDesktop` for legacy desktop support

## Related

- Comfy-Org/ComfyUI-Desktop-2.0-Beta#415
- Backend: Comfy-Org/ComfyUI `feature/generic-feature-flag-cli`
- Launcher: Comfy-Org/ComfyUI-Desktop-2.0-Beta#418

Co-authored-by: Amp <amp@ampcode.com>
2026-04-17 13:45:41 -07:00
Christian Byrne
836cab1b38 fix: deploy website previews via GitHub Actions instead of Vercel auto-deploy (#11289)
## Summary

Vercel's auto-deploy triggers on every PR because files outside
workspace packages (e.g. `browser_tests/`, `src/`) are treated as global
changes by the monorepo skip logic.

## Changes

- **What**: Replace Vercel's GitHub integration with a GitHub Action
(`ci-vercel-website-preview.yaml`) that uses `paths:` filtering to only
deploy when `apps/website/`, `packages/design-system/`, or
`packages/tailwind-utils/` change. Add `vercel.json` with
`github.enabled: false` to disable Vercel's automatic GitHub
integration.

## Setup required after merge

Three GitHub repo secrets are needed. All secrets are scoped per-project
using the `VERCEL_WEBSITE_*` prefix. Future Vercel projects would follow
the same convention (e.g. `VERCEL_DOCS_*`).

### Step 1: Create a Vercel API Token

1. Go to [vercel.com/account/tokens](https://vercel.com/account/tokens)
2. Click **Create Token**
3. Fill in the form:
   - **Token Name**: `github-actions-website`
- **Scope**: Select the **Comfy-Org** team (not "Full Account" — scope
it to the team that owns the project)
- **Expiration**: Choose **No Expiration** (or set a long expiration
like 1 year — if it expires the workflow will silently fail)
4. Click **Create**
5. **Copy the token immediately** — it is only shown once

### Step 2: Get Vercel Org ID and Project ID

1. Go to
[vercel.com/comfyui/website-frontend/settings](https://vercel.com/comfyui/website-frontend/settings)
2. Scroll down to the **Project ID** field — copy this value
3. Go to
[vercel.com/teams/comfyui/settings](https://vercel.com/teams/comfyui/settings)
(Team Settings → General)
4. Find the **Vercel ID** field (also called Team ID / Org ID) — copy
this value

### Step 3: Add secrets to GitHub

1. Go to
[github.com/Comfy-Org/ComfyUI_frontend/settings/secrets/actions](https://github.com/Comfy-Org/ComfyUI_frontend/settings/secrets/actions)
2. Click **New repository secret** and add each of the three secrets:

| Secret name | Value |
|---|---|
| `VERCEL_WEBSITE_TOKEN` | The token from Step 1 |
| `VERCEL_WEBSITE_ORG_ID` | The team/org ID from Step 2 |
| `VERCEL_WEBSITE_PROJECT_ID` | The project ID from Step 2 |

> **Note:** The `vercel.json` added by this PR (`github.enabled: false`)
automatically disables Vercel's built-in auto-deploy — no dashboard
changes needed.

## Review Focus

- Verify the `paths:` filter covers all dependencies of `apps/website`
- Confirm the PR comment logic is sound (creates once, updates on
subsequent pushes)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-17 17:54:26 +00:00
Dante
7ffaff7e1b test: cover useBillingPlans and tierBenefits (#11318)
Closes coverage gaps in `src/platform/cloud/subscription/` as part of
the unit-test backfill.

## Testing focus

`useBillingPlans` holds **module-scoped refs** (`plans`,
`currentPlanSlug`, `isLoading`, `error`). If state leaks between tests,
failures get masked as false-green. The suite uses `vi.resetModules()` +
dynamic `import()` in every test to get a fresh instance — state
isolation is the primary design constraint here.

### `useBillingPlans` (12 tests)

- **Concurrent-call dedup.** The \`isLoading\` guard is validated by
creating a pending promise, firing a second \`fetchPlans()\` while the
first is in-flight, and asserting the mock is called **exactly once**.
- **Error branching.** \`Error\` instance → \`.message\` captured.
Non-Error rejection → fallback string (\`'Failed to fetch plans'\`).
Both paths also verify \`console.error\` logging via a spy.
- **Error-reset invariant.** After a failure, a subsequent success must
null out \`error.value\` — order-dependent and easy to regress.
- **Shared-state invariant.** Two separate \`useBillingPlans()\` calls
return refs pointing at the same module-level state.
- **Computed filtering.** \`monthlyPlans\` / \`annualPlans\` partition
by duration — assertions on distinct output, not input re-assertion.

### \`tierBenefits\` (7 tests)

- Table-driven across all \`TierKey\` values for \`maxDuration\`,
\`addCredits\`, \`customLoRAs\` branches.
- \`monthlyCredits\` free-tier path including the
\`remoteConfig.free_tier_credits\` null fallback.
- Translator/formatter forwarding verified by spy.

## Principles applied

- No mocks of \`vue\`, \`pinia\`, or \`@vueuse/core\` — only our own
\`workspaceApi\`.
- Behavioral assertions only — no return-shape checks.
- All 19 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-17 13:55:49 +00:00
jaeone94
5d04df7b2c fix: prevent duplicate prepareForSave and conflicting is_new telemetry on self-overwrite Save As (#11329)
## Summary

Follow-up to PR #10816. Fixes a telemetry semantic bug in
`saveWorkflowAs` that emitted two conflicting events for a single user
action.

### What changed

- `saveWorkflowAs` self-overwrite branch now calls
`workflowStore.saveWorkflow` directly instead of delegating to the
`saveWorkflow()` wrapper. The wrapper would run `prepareForSave` a
second time and emit `trackWorkflowSaved({ is_new: false })`, which then
conflicted with the outer `saveWorkflowAs`'s `trackWorkflowSaved({
is_new: true })` for the same user action.
- Added regression tests asserting a single `trackWorkflowSaved` call
with `{ is_new: true }` and a single `prepareForSave` invocation on both
the self-overwrite and copy paths.

### Issues fixed

- Fixes #10819

### Why no E2E test

The bug and fix are entirely about observability (how many telemetry
events are emitted and with what payload). There is no user-visible
change — the file is saved correctly in both pre- and post-fix cases,
and `is_new` values are never rendered in the UI. Playwright tests
cannot directly verify `trackWorkflowSaved` call counts/payloads without
intercepting outbound analytics traffic, which is not a pattern used
elsewhere in `browser_tests/`. Unit tests at the service boundary are
the appropriate level for this contract: they mock `useTelemetry` and
can assert exact call count and payload deterministically.

### Test plan

- [x] `pnpm test:unit
src/platform/workflow/core/services/workflowService.test.ts` — 56 tests
pass (including 2 new regression tests + 1 expanded assertion)
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11329-fix-prevent-duplicate-prepareForSave-and-conflicting-is_new-telemetry-on-self-overwrite-3456d73d36508192875ed5e70ab9c359)
by [Unito](https://www.unito.io)
2026-04-17 09:29:03 +00:00
350 changed files with 20938 additions and 2485 deletions

View File

@@ -0,0 +1,125 @@
---
name: playwright-test-generator
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
model: sonnet
color: blue
---
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
application behavior.
# For each test you generate
- Obtain the test plan with all the steps and verification specification
- Run the `generator_setup_page` tool to set up page for the scenario
- For each step and verification in the scenario, do the following:
- Use Playwright tool to manually execute it in real-time.
- Use the step description as the intent for each Playwright tool call.
- Retrieve generator log via `generator_read_log`
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
- File should contain single test
- File name must be fs-friendly scenario name
- Test must be placed in a describe matching the top-level test plan item
- Test title must match the scenario name
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
multiple actions.
- Always use best practices from the log when generating tests.
<example-generation>
For following plan:
```markdown file=specs/plan.md
### 1. Adding New Todos
**Seed:** `tests/seed.spec.ts`
#### 1.1 Add Valid Todo
**Steps:**
1. Click in the "What needs to be done?" input field
#### 1.2 Add Multiple Todos
...
```
Following file is generated:
```ts file=add-valid-todo.spec.ts
// spec: specs/plan.md
// seed: tests/seed.spec.ts
test.describe('Adding New Todos', () => {
test('Add Valid Todo', async { page } => {
// 1. Click in the "What needs to be done?" input field
await page.click(...);
...
});
});
```
</example-generation>
## ComfyUI Project Context
### Required Import Pattern
Generated tests MUST use ComfyUI fixtures, not generic `@playwright/test`:
```typescript
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
```
### Fixture Object
Tests receive `comfyPage` (not `page`) as their fixture:
```typescript
test('my test', async ({ comfyPage }) => {
// Access raw page via comfyPage.page if needed
})
```
### Key APIs
| Need | Use | Notes |
| ---------------- | ---------------------------------------------------- | --------------------------------- |
| Canvas element | `comfyPage.canvas` | Pre-configured Locator |
| Wait for render | `comfyPage.nextFrame()` | After canvas mutations |
| Load workflow | `comfyPage.workflow.loadWorkflow('name')` | Assets in `browser_tests/assets/` |
| Get node by type | `comfyPage.nodeOps.getNodeRefsByType('KSampler')` | Returns NodeReference[] |
| Search box | `comfyPage.searchBox.fillAndSelectFirstNode('name')` | Opens on canvas dblclick |
| Settings | `comfyPage.settings.setSetting(key, value)` | Clean up in afterEach |
| Keyboard | `comfyPage.keyboard.press('Delete')` | Focus canvas first |
| Context menu | `comfyPage.contextMenu` | Right-click interactions |
### Mandatory Test Structure
Every generated test must:
1. Be wrapped in `test.describe('Name', { tag: ['@canvas'] }, () => { ... })`
2. Include `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })`
3. Use descriptive test names (not "test" or "test1")
### Anti-Patterns — NEVER Use
- ❌ `page.goto()` — fixture handles navigation
- ❌ `page.waitForTimeout()` — use `comfyPage.nextFrame()` or retrying assertions
- ❌ `import from '@playwright/test'` — use `from '../fixtures/ComfyPage'`
- ❌ Bare `page.` references — use `comfyPage.page.` if you need raw page access
### Reference
Read the fixture code for full API surface:
- `browser_tests/fixtures/ComfyPage.ts` — main fixture
- `browser_tests/fixtures/helpers/` — helper classes
- `browser_tests/fixtures/components/` — page object components
- See also: `.claude/skills/codegen-transform/SKILL.md` for transform rules

View File

@@ -0,0 +1,91 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests
tools: Glob, Grep, Read, LS, Edit, MultiEdit, Write, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
model: sonnet
color: red
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
2. **Debug failed tests**: For each failing test run `test_debug`.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high confidence the test is correct, do not auto-skip by default.
- Summarize root-cause evidence and escalate as a likely app regression.
- Use `test.fixme()` only when a known issue is documented and referenced, and include a short rationale comment.
Auto-skipping can mask real regressions — require explicit justification.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis
## ComfyUI Project Context
### Custom Fixtures
Tests in this project use `comfyPage` fixture, not bare `page`. When healing:
- Replace any `page.` references with `comfyPage.page.` if adding new code
- Use `comfyPage.nextFrame()` instead of adding `waitForTimeout()`
- Use fixture helpers (`comfyPage.nodeOps`, `comfyPage.canvas`, etc.) over raw locators
### Common Failure Causes in ComfyUI Tests
1. **Missing `nextFrame()`**: Canvas operations need `await comfyPage.nextFrame()` after mutations. This is the #1 cause of "works locally, fails in CI" issues.
2. **Canvas focus required**: Keyboard shortcuts won't work unless `await comfyPage.canvas.click()` is called first.
3. **Node position drift**: Pixel coordinates can shift between environments. When possible, replace with node references:
```typescript
// Instead of: canvas.click({ position: { x: 423, y: 267 } })
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await node.click('title')
```
4. **Settings pollution**: Settings persist across tests on the backend. Always reset changed settings in `afterEach`.
5. **Drag animation timing**: Use `{ steps: 10 }` option for drag operations, not `{ steps: 1 }`.
### Healing Safety Rules
- ❌ NEVER add `waitForTimeout()` — always use retrying assertions or `nextFrame()`
- ❌ NEVER "fix" a test by weakening assertions (e.g., removing an assertion that fails)
- ❌ NEVER modify the application code — only modify test code
- ⚠️ If a test fails because expected UI elements are missing, the app may have a regression — mark as `test.fixme()` with explanation, don't "heal" the assertion away
- ⚠️ If a test fails only in CI but passes locally, likely missing `nextFrame()` — don't mask with timeouts
### Reference
- `browser_tests/fixtures/ComfyPage.ts` — full fixture API
- `browser_tests/fixtures/helpers/` — available helper classes
- `.claude/skills/writing-playwright-tests/SKILL.md` — testing conventions
- `.claude/skills/codegen-transform/SKILL.md` — transform rules

View File

@@ -0,0 +1,89 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_run_code, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page, mcp__playwright-test__planner_save_plan
model: sonnet
color: green
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use `browser_*` tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Submit your test plan using `planner_save_plan` tool.
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.
## ComfyUI Project Context
### Application Overview
ComfyUI is a **canvas-based node graph editor** for AI image generation. It is a complex SPA with:
- A **LiteGraph canvas** where users create workflows by connecting nodes
- A **Vue 3 sidebar** with node library, workflows panel, and settings
- A **topbar** with queue/run buttons and workspace controls
- A **search box** for finding and adding nodes (opens on double-click)
- WebSocket-based real-time communication with a Python backend
### Exploration Tips
- Start by loading a workflow: the app is most useful with nodes on the canvas
- Key UI areas to explore: canvas interactions, sidebar panels, topbar buttons, search box, context menus, settings dialog
- Double-click the canvas to open the node search box
- Right-click nodes/canvas for context menus
- The bottom panel shows job queue and execution logs
### Test Environment
- The seed test uses `comfyPageFixture` which provides a `comfyPage` object with extensive helpers
- Workflows (JSON files) are loaded via `comfyPage.workflow.loadWorkflow('name')`
- Available workflow assets are in `browser_tests/assets/`
- The backend MUST be running with `--multi-user` flag
- A Vite dev server runs on `:5173`
### When Creating Test Plans
- Reference specific workflow assets when a scenario needs a starting state
- Note that canvas interactions use pixel coordinates — these may vary across environments
- Distinguish between "canvas tests" (LiteGraph) and "UI tests" (Vue components)
- Include tags in your plans: `@canvas`, `@widget`, `@sidebar`, `@smoke`, `@screenshot`
- Reference `browser_tests/fixtures/ComfyPage.ts` for available test helpers

View File

@@ -0,0 +1,95 @@
---
name: codegen-transform
description: 'Transforms raw Playwright codegen output into ComfyUI convention-compliant tests. Use when: user pastes raw codegen, asks to convert raw Playwright code, refactor recorded tests, or rewrite to project conventions. Triggers on: transform codegen, convert raw test, rewrite to conventions, codegen output, raw playwright.'
---
# Codegen → Convention Transform
Transform raw Playwright codegen output into tests that follow ComfyUI conventions.
## When to Use
- QA tester recorded a test with `pnpm comfy-test record` and wants refinement
- Developer pasted raw `npx playwright codegen` output
- Agent needs to post-process Playwright test agent output
- Reviewing a test that uses raw `page.*` calls instead of fixture helpers
## Reference Documentation
Before transforming, read these existing docs for full context:
| Document | What it covers |
| ------------------------------------- | ----------------------------------------------------------------------- |
| `docs/guidance/playwright.md` | Playwright conventions, type assertions, assertion best practices, tags |
| `browser_tests/AGENTS.md` | Directory structure, polling assertions, gotchas, quality checks |
| `browser_tests/fixtures/ComfyPage.ts` | Main fixture API (source of truth for all helpers) |
| `browser_tests/fixtures/helpers/` | Focused helper classes (canvas, keyboard, workflow, etc.) |
## Transform Rules
The programmatic transform engine lives in `tools/test-recorder/src/transform/rules.ts`. Apply these replacements in order:
| Raw codegen | Convention replacement | Why |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- |
| `import { test, expect } from '@playwright/test'` | `import { comfyPageFixture as test, comfyExpect as expect } from '../fixtures/ComfyPage'` | Use custom fixtures with ComfyUI helpers |
| `test('test', async ({ page }) =>` | `test('descriptive-name', async ({ comfyPage }) =>` | Use comfyPage fixture, descriptive names |
| `await page.goto('http://...')` | **Remove entirely** | Fixture handles navigation automatically |
| `page.locator('canvas')` | `comfyPage.canvas` | Pre-configured canvas locator |
| `page.waitForTimeout(N)` | `comfyPage.nextFrame()` | Never use arbitrary waits |
| `page.getByPlaceholder('Search Nodes...')` | `comfyPage.searchBox.input` | Use search box page object |
| `page` (bare reference) | `comfyPage.page` | Access raw page through fixture |
| Bare `test(...)` | `test.describe('Feature', { tag: ['@canvas'] }, () => { test(...) })` | All tests need describe + tags |
| No cleanup | Add `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })` | Canvas tests need cleanup |
## Canvas Coordinates → Node References
Raw codegen records fragile pixel coordinates. Replace with node references when possible:
```typescript
// ❌ Raw codegen — fragile pixel coordinates
await page.locator('canvas').click({ position: { x: 423, y: 267 } })
// ✅ If clicking a specific node
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await node.click('title')
// ✅ If double-clicking canvas to open search
await comfyPage.canvas.dblclick({ position: { x: 500, y: 400 } })
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
```
**When to keep coordinates**: Canvas background clicks (pan, zoom), empty area clicks to deselect. These are inherently position-based.
## Decision Guide
| Question | Answer |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| Canvas or DOM interaction? | Canvas: `comfyPage.nodeOps.*`. DOM: `comfyPage.vueNodes.*` (needs opt-in) |
| Need `nextFrame()`? | Yes after canvas mutations. No after `loadWorkflow()`, no after DOM clicks |
| Which tag? | `@canvas` for canvas tests, `@widget` for widget tests, `@screenshot` for visual regression |
| Need cleanup? | Yes for canvas tests (`resetView`), yes if changing settings (`setSetting` back) |
| Keep pixel coords? | Only for empty canvas clicks. Replace with node refs for node interactions |
| Use `page` directly? | Only via `comfyPage.page` for Playwright APIs not wrapped by fixtures |
## Anti-Patterns
1. **Never use `waitForTimeout`** → use `nextFrame()` or retrying assertions
2. **Never use `page.goto`** → fixture handles navigation
3. **Never import from `@playwright/test`** → use `../fixtures/ComfyPage`
4. **Never use bare CSS selectors** → use test IDs or semantic locators
5. **Never share state between tests** → each test is independent
6. **Never commit local screenshots** → Linux CI generates baselines
## For Deeper Reference
Read fixture code directly — it's the source of truth:
| Purpose | Path |
| ----------------- | ------------------------------------------ |
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
| Helper classes | `browser_tests/fixtures/helpers/` |
| Component objects | `browser_tests/fixtures/components/` |
| Test selectors | `browser_tests/fixtures/selectors.ts` |
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
| Existing tests | `browser_tests/tests/` |
| Test assets | `browser_tests/assets/` |

View File

@@ -98,3 +98,50 @@ jobs:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -0,0 +1,90 @@
---
name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
- name: Save PR metadata
run: |
mkdir -p temp/vercel-preview
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
- name: Upload preview metadata
uses: actions/upload-artifact@v6
with:
name: vercel-preview
path: temp/vercel-preview
deploy-production:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,74 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
permissions:
contents: read
pull-requests: write
actions: read
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
if: steps.pr-meta.outputs.skip != 'true'
id: meta
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
run: |
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./preview-report.md
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
token: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -59,6 +59,7 @@ coverage/
/playwright/.cache/
browser_tests/**/*-win32.png
browser_tests/local/
browser_tests/tests/_recording-session.spec.ts
.env

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright-test": {
"command": "pnpm",
"args": ["dlx", "@playwright/mcp@0.0.68"]
}
}
}

View File

@@ -10,6 +10,7 @@
"dist/*",
"packages/registry-types/src/comfyRegistryTypes.ts",
"playwright-report/*",
"scripts/patch-playwright-agents.js",
"src/extensions/core/*",
"src/scripts/*",
"src/types/generatedManagerTypes.ts",
@@ -115,6 +116,12 @@
"no-console": "allow"
}
},
{
"files": ["tools/test-recorder/**/*.ts"],
"rules": {
"no-console": "allow"
}
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],

View File

@@ -49,6 +49,21 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
- `pnpm preview`: Preview the production build locally
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm comfy-test record`: Interactive test recorder (guided setup for non-devs)
- `pnpm comfy-test transform <file>`: Transform raw codegen to conventions
- `pnpm comfy-test check`: Check environment prerequisites
- `pnpm comfy-test list`: List available test workflows
### Playwright Test Agents (`.claude/agents/`)
| Agent | Responsibility |
| ------------------------------ | ---------------------------------------------------------------------------------- |
| `playwright-test-planner.md` | Explores the app, identifies testable scenarios, creates structured test plans |
| `playwright-test-generator.md` | Generates Playwright test code from plans using ComfyUI fixtures and conventions |
| `playwright-test-healer.md` | Diagnoses and fixes failing tests; escalates regressions rather than auto-skipping |
Guardrails: agents must use `comfyPage` fixture (not bare `page`), never add `waitForTimeout()`, never weaken assertions, and reference `.claude/skills/codegen-transform/SKILL.md` for transform rules.
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm typecheck`: Vue TSC type checking

View File

@@ -0,0 +1,137 @@
import { expect, test } from '@playwright/test'
test.describe('Cloud page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /The full power of/i, level: 1 })
).toBeVisible()
await expect(
page.getByText(/The easiest way to start with ComfyUI/)
).toBeVisible()
})
test('HeroSection has CTA button linking to cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /TRY COMFY CLOUD FOR FREE/i })
await expect(cta).toBeVisible()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Powerful GPUs',
'All models',
'More control',
'Community workflows'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
await expect(
page.getByRole('heading', { name: /leading AI models/i })
).toBeVisible()
const grid = page.locator('.grid', {
has: page.getByText('Grok Imagine')
})
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {
const cta = page.getByRole('link', {
name: /EXPLORE WORKFLOWS/i
})
await expect(cta.first()).toBeVisible()
await expect(cta.first()).toHaveAttribute(
'href',
'https://comfy.org/workflows'
)
})
test('AudienceSection heading and cards are visible', async ({ page }) => {
await expect(page.getByText(/creators/i).first()).toBeVisible()
for (const label of ['CREATORS', 'TEAMS & STUDIOS']) {
await expect(page.getByText(label).first()).toBeVisible()
}
})
test('PricingSection heading and CTA are visible', async ({ page }) => {
await expect(page.getByText(/Simple, credit-based pricing/)).toBeVisible()
const cta = page.getByRole('link', { name: /SEE PRICING PLANS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', '/cloud/pricing')
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('FAQSection heading is visible with 15 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(15)
})
})
test.describe('Cloud FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('all FAQs are expanded by default', async ({ page }) => {
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
await firstQuestion.click()
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
})
})

View File

@@ -0,0 +1,167 @@
import { expect, test } from '@playwright/test'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ page }) => {
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Unlimited',
'Any model',
'Your machine',
'Free. Open Source'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('EcoSystemSection heading is visible', async ({ page }) => {
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('ProductCardsSection links to cloud, api, enterprise', async ({
page
}) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
test('FAQSection heading is visible with 8 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(8)
})
})
test.describe('FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('all FAQs are expanded by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeVisible()
})
test('clicking an expanded FAQ collapses it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
})
})
test.describe('Download page mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('CloudBannerSection is visible', async ({ page }) => {
await expect(page.getByText(/Need more power/)).toBeVisible()
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ page }) => {
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await downloadBtn.scrollIntoViewIfNeeded()
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
expect(downloadBox, 'download button bounding box').not.toBeNull()
expect(githubBox, 'github button bounding box').not.toBeNull()
expect(githubBox!.y).toBeGreaterThan(downloadBox!.y)
})
})

View File

@@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1471_12658)">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z" fill="white"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z" fill="white"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z" fill="white"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1471_12658">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.0589 13.0316H16.014V19.1831H24.6589C24.5199 20.0537 24.2078 20.9101 23.7509 21.691C23.2273 22.5858 22.5801 23.2669 21.9167 23.7857C19.9294 25.3395 17.6126 25.6572 16.0035 25.6572C11.9389 25.6572 8.46594 22.9766 7.12148 19.3341C7.06723 19.2019 7.0312 19.0654 6.98733 18.9304C6.69023 18.0034 6.5279 17.0215 6.5279 16.001C6.5279 14.939 6.70369 13.9223 7.0242 12.9621C8.28844 9.17522 11.8397 6.34675 16.0064 6.34675C16.8445 6.34675 17.6516 6.44854 18.417 6.65159C20.1661 7.11561 21.4034 8.0295 22.1615 8.75237L26.7361 4.18101C23.9534 1.57756 20.3259 3.9369e-09 15.9988 3.9369e-09C12.5396 -7.59723e-05 9.34593 1.09971 6.72881 2.95838C4.60641 4.46571 2.86573 6.48384 1.69099 8.82767C0.598311 11.0009 0 13.4092 0 15.9986C0 18.5881 0.599225 21.0215 1.69191 23.1746V23.1891C2.84605 25.4749 4.5338 27.4431 6.58508 28.9435C8.3771 30.2543 11.5904 32 15.9988 32C18.534 32 20.7809 31.5336 22.7625 30.6595C24.192 30.029 25.4585 29.2066 26.6052 28.1496C28.1203 26.753 29.3069 25.0255 30.1168 23.038C30.9268 21.0505 31.36 18.8029 31.36 16.3662C31.36 15.2314 31.2483 14.0791 31.0589 13.0316Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="32" height="31" viewBox="0 0 32 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.3587 19.8988L22.9972 11.9646C23.5187 11.5757 24.2642 11.7274 24.5127 12.3316C25.8207 15.5179 25.2363 19.3471 22.634 21.9762C20.0318 24.6052 16.411 25.1818 13.1015 23.8686L9.48617 25.5598C14.6716 29.1406 20.9684 28.255 24.9032 24.277C28.0243 21.1237 28.9909 16.8255 28.0871 12.9496L28.0952 12.9578C26.7845 7.26377 28.4175 4.98781 31.7625 0.333808C31.8416 0.223459 31.9208 0.113108 32 0L27.5982 4.44709V4.4333L12.356 19.9016" fill="white"/>
<path d="M10.1634 21.8272C6.44151 18.2353 7.0832 12.6764 10.2589 9.47079C12.6072 7.09824 16.4546 6.12993 19.8133 7.55344L23.4204 5.87061C22.7706 5.3961 21.9377 4.88574 20.982 4.52709C16.6622 2.73116 11.4904 3.62499 7.97884 7.16997C4.60108 10.5825 3.53887 15.8297 5.36292 20.3071C6.72549 23.6535 4.49185 26.0204 2.24183 28.4096C1.44449 29.2564 0.644421 30.1034 0 31L10.1606 21.8299" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.1935 32L24.9858 15.5745L27.22 19.5198H31.4952L27.22 12.0998L29.4765 8.3063H12.2615L14.5178 4.36093L12.2615 0.332235L7.41455 8.3063H2.39172L11.6896 23.9422H7.41455L4.85911 27.9711H13.194L15.6878 32L15.6431 31.9985C15.4212 31.9838 15.2231 31.8597 15.1159 31.668L13.1938 28.3031H4.85911C4.6194 28.3031 4.40156 28.1756 4.2872 27.9711L2.03092 24.1776C1.85061 23.8609 1.8506 23.4694 2.03092 23.1527L3.95303 19.7881L0.135229 12.9026C-0.0450889 12.586 -0.0450637 12.1944 0.135229 11.8778L2.39151 8.08424C2.50586 7.87972 2.72371 7.75222 2.96341 7.75222H7.41455L11.6896 0.332235C11.8039 0.127714 12.0218 0 12.2615 0H16.7124C16.9521 0 17.17 0.127714 17.2843 0.332235L19.2064 3.69689H27.5411C27.7808 3.69689 27.9986 3.82439 28.113 4.02891L30.3693 7.82245C30.5496 8.13912 30.5496 8.53061 30.3693 8.84729L28.113 12.6408L31.6316 19.0976C31.8119 19.4143 31.8119 19.8058 31.6316 20.1224L29.3751 23.916C29.2608 24.1204 29.0431 24.248 28.8034 24.248H24.9856L20.7106 31.668C20.5962 31.8725 20.3784 32 20.1387 32H16.1935Z" fill="white"/>
<path d="M22.0609 13.703H10.6869L16.1935 23.001L22.0609 13.703Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.1935 32L24.9858 15.5745L27.22 19.5198H31.4952L27.22 12.0998L29.4765 8.3063H12.2615L14.5178 4.36093L12.2615 0.332235L7.41455 8.3063H2.39172L11.6896 23.9422H7.41455L4.85911 27.9711H13.194L15.6878 32L15.6431 31.9985C15.4212 31.9838 15.2231 31.8597 15.1159 31.668L13.1938 28.3031H4.85911C4.6194 28.3031 4.40156 28.1756 4.2872 27.9711L2.03092 24.1776C1.85061 23.8609 1.8506 23.4694 2.03092 23.1527L3.95303 19.7881L0.135229 12.9026C-0.0450889 12.586 -0.0450637 12.1944 0.135229 11.8778L2.39151 8.08424C2.50586 7.87972 2.72371 7.75222 2.96341 7.75222H7.41455L11.6896 0.332235C11.8039 0.127714 12.0218 0 12.2615 0H16.7124C16.9521 0 17.17 0.127714 17.2843 0.332235L19.2064 3.69689H27.5411C27.7808 3.69689 27.9986 3.82439 28.113 4.02891L30.3693 7.82245C30.5496 8.13912 30.5496 8.53061 30.3693 8.84729L28.113 12.6408L31.6316 19.0976C31.8119 19.4143 31.8119 19.8058 31.6316 20.1224L29.3751 23.916C29.2608 24.1204 29.0431 24.248 28.8034 24.248H24.9856L20.7106 31.668C20.5962 31.8725 20.3784 32 20.1387 32H16.1935Z" fill="white"/>
<path d="M22.0609 13.703H10.6869L16.1935 23.001L22.0609 13.703Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 13L7 7L1 0.999999" stroke="#211927" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -0,0 +1,15 @@
<svg width="125" height="23" viewBox="0 0 125 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.624 13.5714H102.72L105.521 4.7427H109.112L104.492 17.6087C102.983 21.8406 101.427 22.4 99.2481 22.4C98.4106 22.4 97.6921 22.3513 97.2372 22.3027V19.5059L97.4957 19.5312C97.7832 19.562 98.1651 19.6032 98.7218 19.6032C99.8707 19.6032 100.326 19.1652 100.828 17.9005L100.996 17.5114L95.9202 4.7427H99.5359L102.624 13.5714Z" fill="#C2BFB9"/>
<path d="M6.34494 4.45081C10.3191 4.45087 11.8513 6.95585 12.3302 8.51244L9.07418 9.24216C8.79582 8.34683 8.13559 7.08628 6.50571 7.00832L6.34494 7.00484C4.57317 7.00484 3.42365 8.41543 3.42365 11.1881C3.42369 13.9606 4.5732 15.3713 6.34494 15.3713C8.16444 15.3713 8.85871 14.009 9.12207 12.866L12.5218 13.2795C11.9951 15.4197 10.3193 17.9248 6.34494 17.9249C2.41839 17.9249 6.38906e-05 15.3226 0 11.1881C0 7.05343 2.41834 4.45081 6.34494 4.45081Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.1848 4.4528C37.0563 4.53422 39.6265 7.17738 39.3201 11.9178H30.0783C30.1263 14.0337 31.2519 15.5173 32.9996 15.5173C34.8431 15.5172 35.5373 14.3496 35.8007 13.5227L39.2009 13.7903C38.6981 15.6144 37.0219 17.9248 32.9996 17.9249C29.121 17.9249 26.6547 15.2983 26.6547 11.2124C26.6547 7.0777 29.0491 4.45081 32.9996 4.45081L33.1848 4.4528ZM32.9996 6.81025C31.2279 6.81025 30.246 8.12338 30.1262 9.70432H35.8965C35.7767 8.12342 34.7953 6.81029 32.9996 6.81025Z" fill="#C2BFB9"/>
<path d="M71.9332 4.45081C74.878 4.45081 77.129 5.95871 77.5359 8.19622L74.4474 8.63406C74.1924 7.40883 73.2402 6.75409 72.049 6.71444L71.9332 6.71295C70.7363 6.71296 69.874 7.24781 69.874 8.22055C69.874 9.16907 70.8078 9.41244 72.1008 9.65567L73.3939 9.85026C75.6682 10.2394 77.8711 10.9204 77.8711 13.8146C77.8711 16.6116 75.357 17.9248 72.0529 17.9249C68.7253 17.9249 66.4501 16.3684 66.0193 13.8632L69.1561 13.4254L69.1854 13.5669C69.5113 15.0012 70.5693 15.6632 72.0769 15.6632C73.5135 15.6632 74.4474 15.0307 74.4474 13.9362C74.4474 13.0364 73.5135 12.6713 72.0769 12.4281L70.6881 12.2097C68.653 11.8692 66.4982 11.1391 66.4982 8.36649C66.4983 5.9101 68.9644 4.45082 71.9332 4.45081Z" fill="#C2BFB9"/>
<path d="M112.823 17.6871H109.232V14.0385H112.823V17.6871Z" fill="#C2BFB9"/>
<path d="M116.914 17.6871H113.323V14.039H116.914V17.6871Z" fill="#C2BFB9"/>
<path d="M124.6 17.6871H117.382V14.039H124.6V17.6871Z" fill="#C2BFB9"/>
<path d="M83.5192 4.7427H86.1531V7.02916H83.5192V13.693C83.5192 14.8604 83.9504 14.9821 85.0038 14.9821C85.339 14.9821 85.7696 14.9822 86.0569 14.9578V17.6087C85.7454 17.6573 84.6924 17.6573 83.8784 17.6573C81.2928 17.6573 80.1674 17.0981 80.1674 14.5686V7.02916H78.156V4.7427H80.1674V1.14323H83.5192V4.7427Z" fill="#C2BFB9"/>
<path d="M55.8226 4.45081C58.121 4.45081 60.0604 5.88579 60.0604 9.14486V17.633H56.7325V10.0205C56.7325 7.95318 55.9182 7.19943 54.5296 7.19943C53.2366 7.19953 52.0638 8.31817 52.0638 10.6043V17.633H48.7115V10.0205C48.7115 7.95321 47.9216 7.19945 46.533 7.19943C45.24 7.19943 44.0666 8.31807 44.0666 10.6043V17.633H40.7148V4.7427H43.9229V6.46971H44.0187C44.8807 5.2293 46.1261 4.45081 47.826 4.45081C49.3822 4.45087 50.795 5.10777 51.513 6.56701C52.5428 5.35093 53.7876 4.45086 55.8226 4.45081Z" fill="#C2BFB9"/>
<path d="M16.9677 6.44539H17.0635C17.9493 5.20501 19.2662 4.45085 21.0139 4.45081C23.3603 4.45081 25.3714 5.88579 25.3714 9.14486V17.6087H22.0436V10.0205C22.0436 7.95328 21.1817 7.19951 19.6496 7.19943C18.3088 7.19943 16.9677 8.46404 16.9677 10.7746V17.6087H13.6159V0H16.9677V6.44539Z" fill="#C2BFB9"/>
<path d="M65.0639 17.6087H61.7121V4.7427H65.0639V17.6087Z" fill="#C2BFB9"/>
<path d="M94.6844 4.45578C94.9211 4.46603 95.1545 4.49342 95.3338 4.54811V7.73407C95.0226 7.68543 94.5196 7.63677 94.0173 7.63677C92.1019 7.6368 90.6895 8.4884 90.6895 11.1151V17.6087H87.3372V4.7427H90.5458V6.71295H90.6411C91.3116 5.39959 92.5809 4.45081 94.4484 4.45081L94.6844 4.45578Z" fill="#C2BFB9"/>
<path d="M65.0639 2.94272H61.7121V0H65.0639V2.94272Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,7 @@
<svg width="105" height="15" viewBox="0 0 105 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.539 0.0285426C22.6544 -0.0351846 25.988 0.0279171 29.1212 0.0264352L32.1742 0.0199332C32.9185 0.018159 33.8039 -0.0152019 34.5256 0.11456C35.3509 0.266315 36.1211 0.634326 36.7577 1.18105C37.6967 1.97593 38.2784 3.11376 38.3729 4.3405C38.4839 5.82246 37.9664 7.19079 36.8332 8.15921C36.5415 8.40857 35.9614 8.79166 35.7424 9.04646C35.7383 9.43601 36.8893 11.0059 37.1467 11.4269C37.4643 11.9465 38.4111 11.7772 38.9199 11.7756C39.3943 11.7772 39.8685 11.793 40.342 11.8229C40.4037 12.5345 40.3634 13.9093 40.3517 14.664C39.0437 14.7314 37.3846 14.6838 36.0336 14.6838C34.657 14.6837 32.979 14.7319 31.644 14.6624C31.6103 14.2318 31.6332 13.4746 31.6327 13.02L31.6361 11.814C32.1247 11.7835 32.5976 11.8061 33.0846 11.7834C33.319 11.7725 33.4799 11.7405 33.5165 11.4949C33.4337 11.2769 33.1746 10.9388 33.0331 10.747C32.7459 10.3576 32.5042 9.90935 32.142 9.58871C31.967 9.43386 31.4996 9.39602 31.2878 9.39494C29.871 9.38766 28.4576 9.39504 27.0412 9.40171C26.4322 9.40545 25.8162 9.39617 25.2105 9.46037C25.198 9.95812 24.9581 11.8037 25.6817 11.7886C26.5399 11.7706 27.404 11.7843 28.2643 11.8099C28.2959 12.6677 28.2852 13.8088 28.2613 14.6625C27.0013 14.7337 25.3367 14.6856 24.0277 14.6841C22.6169 14.6825 20.913 14.7327 19.5435 14.6627C19.5121 13.7618 19.5376 12.7254 19.539 11.8145C20.0584 11.7729 21.8513 11.8658 22.1261 11.6122C22.2789 11.0808 22.2133 8.34042 22.2097 7.6769C22.206 6.9765 22.2904 3.60502 22.0911 3.13566C21.8255 2.99389 19.9813 3.03216 19.5159 3.00974C19.4729 2.62606 19.453 0.3621 19.539 0.0285426ZM25.1781 6.41333C25.7864 6.42789 26.403 6.43338 27.0112 6.4283C29.3214 6.40892 31.6425 6.48496 33.9495 6.40241C34.0841 6.39759 34.5121 6.16284 34.6315 6.08213C35.0228 5.71678 35.2992 5.35081 35.3173 4.7881C35.3308 4.32373 35.1606 3.87283 34.8436 3.53331C34.4239 3.08989 33.9742 2.99621 33.3906 2.99441C30.7387 2.98624 28.0784 3.00441 25.4273 3.02022C25.2542 3.04728 25.2743 3.01516 25.1877 3.12383C25.1324 3.75496 25.1485 5.73442 25.1781 6.41333Z" fill="#C2BFB9"/>
<path d="M50.2724 0.138631C51.6828 0.03579 53.2496 0.182878 54.6625 0.122495C56.7584 0.0329492 58.1473 0.23673 59.6382 1.82499C61.3824 3.68336 61.5311 5.19749 61.5316 7.61398C61.5332 8.59624 61.528 9.57851 61.5157 10.5607C61.5127 10.8878 61.4609 11.4889 61.627 11.7565C61.8023 11.9292 64.2099 11.9103 64.6493 11.9184C64.6509 12.866 64.6478 13.8136 64.6391 14.7611C63.7008 14.8175 62.2832 14.7743 61.2886 14.7733C59.5065 14.7717 57.6654 14.7977 55.8904 14.7644C55.8576 13.8841 55.8771 12.8157 55.892 11.9268C56.3996 11.897 58.1832 11.9618 58.435 11.7296C58.6047 11.3174 58.598 9.85935 58.4298 9.4396C58.1735 9.22562 57.086 9.31059 56.6785 9.31623C55.7853 9.3153 48.7631 9.24582 48.3863 9.39335C48.275 9.53964 48.2383 9.77249 48.2404 9.94037C48.2611 11.6716 47.8088 11.991 49.9209 11.8975C50.1037 11.8895 50.661 11.9185 50.8594 11.9251C50.9014 12.7546 50.8803 13.9221 50.861 14.7637C50.0044 14.8179 48.6992 14.7732 47.7992 14.7732L42.1365 14.7669C42.1124 13.8551 42.1244 12.8371 42.14 11.9221C42.7563 11.9001 44.5286 11.9586 44.9981 11.8351C45.043 11.8233 45.0865 11.8065 45.1277 11.7851C45.1698 11.7632 45.2281 11.6483 45.2321 11.6095C45.5513 8.48866 44.4939 5.07258 46.6263 2.39668C47.5759 1.20517 48.672 0.362 50.2724 0.138631ZM55.8069 6.37984C56.2334 6.38256 57.2317 6.32251 57.6008 6.39251C59.4224 6.73786 58.1325 4.41869 57.5218 3.82117C57.1583 3.46885 56.7077 3.22021 56.2155 3.10105C55.5844 2.94746 52.2657 2.99145 51.4515 3.00183C51.3418 3.00391 51.2319 3.00822 51.1221 3.01476C50.2804 3.08849 49.7204 3.39435 49.1449 4.0204C48.8448 4.34691 48.0275 5.86061 48.3348 6.28005C48.8624 6.47634 54.8163 6.38015 55.8069 6.37984Z" fill="#C2BFB9"/>
<path d="M66.1008 0.132078C67.4815 0.0948407 69.0088 0.126689 70.3982 0.126274L82.7239 0.124817C83.3335 0.124915 84.5481 0.0906206 85.1084 0.193006C85.2351 0.383285 85.1848 4.29567 85.1618 4.8387L82.2455 4.84347C82.1717 4.37331 82.2558 3.44598 82.0697 3.11536C82.0035 3.09915 81.9364 3.08718 81.8692 3.07952C81.153 2.99854 72.2869 2.97826 72.0639 3.06634C72.0337 3.07839 71.9855 3.12886 71.9578 3.15083C71.7579 3.52942 71.9947 4.54083 71.8983 5.04801C71.8814 5.21615 71.8947 5.68477 72.0527 5.78369C72.4966 6.06094 73.6589 5.94967 74.1921 5.9527L78.3526 5.97392C78.488 5.97259 78.6126 5.9569 78.7177 6.0434C78.8084 6.24682 78.7966 8.46194 78.7074 8.77012C78.2993 9.11434 72.3674 8.65905 72.0398 9.01363C71.8799 9.32283 71.8573 11.4816 71.9209 11.7506C72.1834 11.9303 74.4105 11.9116 74.8474 11.9209C74.8597 12.8671 74.8607 13.8133 74.8504 14.7595C73.9742 14.8203 72.4085 14.7764 71.4569 14.7732C69.7091 14.7674 67.8562 14.8074 66.1218 14.7628C66.0777 14.1145 66.0895 12.5701 66.1223 11.9225C66.6253 11.8922 68.4592 11.9381 68.774 11.7648C68.9098 11.2104 68.916 3.76877 68.7852 3.15653C68.5156 2.97876 66.5478 3.00636 66.091 2.9977C66.0746 2.15515 66.0464 0.952851 66.1008 0.132078Z" fill="#C2BFB9"/>
<path d="M0 3.97075C0.680008 2.4775 1.72004 1.09533 3.30995 0.49908C3.73281 0.343706 4.17582 0.249971 4.62537 0.220763C5.88343 0.131726 17.0718 0.0347761 17.432 0.19913C17.4429 0.204062 17.4534 0.209482 17.4639 0.215036C17.5863 0.617033 17.5561 4.23071 17.5217 4.84327L14.4665 4.84676C14.4511 4.57601 14.4351 3.33928 14.2915 3.1986C14.1017 3.13857 13.8405 3.10635 13.6413 3.10753C10.8734 3.12384 8.12437 3.13586 5.3552 3.16177C4.03315 3.17415 3.10523 4.28538 2.72969 5.46977C2.44858 6.35631 2.58788 7.38144 2.55855 8.30232C2.53008 9.63214 3.18983 11.1025 4.41563 11.7148C4.93376 11.9737 5.57212 11.9398 6.13799 11.9348C8.63538 11.9151 11.1334 11.9289 13.6307 11.9075C13.8341 11.9057 14.0499 11.8752 14.2419 11.8084C14.4871 11.6127 14.4224 10.4672 14.473 10.0944C14.9745 10.0805 17.1263 10.0064 17.4428 10.1774C17.6064 10.5158 17.5389 14.096 17.5232 14.7353C14.5846 14.7664 11.6458 14.7797 8.707 14.775C7.45695 14.7749 6.20151 14.7635 4.95241 14.7698C2.61924 14.7814 0.911018 13.0104 0 11.0108V3.97075Z" fill="#C2BFB9"/>
<path d="M105 0.127759V4.84265L102.537 4.8407C102.524 4.57401 102.556 3.11933 102.308 3.10405C101.561 3.05828 98.278 2.90385 97.7617 3.1579C97.63 3.63603 97.65 11.2212 97.7817 11.7547C98.077 11.9306 100.223 11.9104 100.712 11.9221C100.78 12.5649 100.734 14.0796 100.726 14.7638C99.8423 14.8242 98.1319 14.779 97.1696 14.7744C95.4085 14.766 93.4956 14.8141 91.7519 14.7607C91.7058 14.02 91.7155 12.6763 91.755 11.9299C92.2841 11.8996 94.2175 11.9179 94.5492 11.7937C94.7056 11.5281 94.6933 4.01885 94.6338 3.22394C94.4123 2.94404 90.2026 2.98239 89.9437 3.13285C89.7483 3.44251 89.7724 4.42536 89.7606 4.83963L86.826 4.84542C86.7767 3.83337 86.7685 1.13399 86.8301 0.135563C88.7768 0.0695231 91.0501 0.126784 93.0168 0.126836L105 0.127759Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,12 @@
<svg width="118" height="22" viewBox="0 0 118 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0298 0C13.03 1.33223 13.0823 3.05715 13.0203 4.35674C12.5297 4.40883 12.0611 4.39599 11.5697 4.39984C9.20775 4.41837 6.83709 4.37161 4.47602 4.40663C4.47935 4.47148 4.491 4.57304 4.47011 4.63014C4.50477 5.73869 4.52926 7.37998 4.47277 8.47402C4.47914 8.53726 4.49478 8.63703 4.48015 8.69487C6.52067 8.8156 8.81129 8.6622 10.8741 8.73886C10.9067 10.0161 10.9269 11.7943 10.8809 13.0738C8.91015 13.2295 6.50525 13.0238 4.47188 13.1369C4.47661 13.2056 4.4903 13.3077 4.46952 13.3687C4.50565 14.4103 4.52334 16.0974 4.47513 17.1432L4.48842 17.3416C4.92992 17.4348 6.22159 17.4012 6.73735 17.4004L10.6232 17.3942C11.3704 17.3932 12.2867 17.3668 13.0153 17.4272C13.0649 18.8228 13.0311 20.3622 13.0274 21.7683H0V0H13.0298Z" fill="#C2BFB9"/>
<path d="M28.9816 0C29.6882 0.472806 30.3169 0.809757 30.4166 1.78273C30.5559 3.14391 30.5099 5.12562 30.4644 6.50056C29.5433 6.55432 27.0931 6.59113 26.2083 6.48786C26.1383 6.55342 26.1721 6.54075 26.0761 6.5419C25.9394 6.30164 25.9933 4.72191 26.0566 4.41106C25.0069 4.34402 22.8858 4.37764 21.7937 4.4087C21.6985 5.48716 21.767 7.57495 21.7733 8.71672C23.3169 8.73062 24.8608 8.73205 26.4044 8.72085C27.8463 8.71837 29.7895 8.36939 30.3563 10.1581C30.4227 10.4794 30.4805 10.843 30.4827 11.1712C30.4998 13.7868 30.4908 16.4035 30.4871 19.0192C30.4866 19.3602 30.4337 19.6526 30.4033 19.9794C30.3023 21.063 29.7051 21.1984 28.9943 21.7683H18.9183C18.8277 21.6461 18.7602 21.6535 18.6522 21.5741C17.9823 21.0811 17.6231 20.832 17.4326 20.032C17.3996 19.8865 17.3935 19.8353 17.3003 19.7157C17.2138 18.6643 17.2794 16.6781 17.2867 15.594C17.2612 15.4679 17.2567 15.3394 17.3198 15.2326L17.4169 15.4618L17.478 15.4889L17.5471 15.1889C18.9397 15.2079 20.3325 15.2037 21.7249 15.1759C21.8084 15.9193 21.7461 16.6513 21.7775 17.3841C22.2776 17.3946 25.7281 17.4316 26.082 17.3706C25.9175 16.2884 26.1722 14.199 26.0188 13.0894C24.7334 13.1336 23.4596 13.123 22.1082 13.1151C21.0343 13.1088 19.1306 13.2867 18.1955 12.6202C17.9119 12.4182 17.4843 11.7548 17.4633 11.4065C17.4064 11.2039 17.2645 11.0063 17.2908 10.8145C17.2246 8.97126 17.3079 7.0791 17.2764 5.23098C17.2595 4.24575 17.2648 3.21037 17.2929 2.22649C17.2951 2.24676 17.4457 1.70297 17.4571 1.65843C17.4876 1.59418 17.5087 1.43756 17.5781 1.30029C17.8952 0.672774 18.3737 0.41943 18.8527 0H28.9816Z" fill="#C2BFB9"/>
<path d="M46.4198 0C47.6718 0.899999 47.8824 1.08409 47.7918 2.72901C47.731 3.83475 47.8975 5.22667 47.7691 6.30865L47.7328 6.48048C47.5838 6.54671 47.2595 6.56281 47.0971 6.55312C45.9297 6.48345 44.7504 6.58741 43.591 6.53451C43.5603 6.5326 43.5093 6.32802 43.4977 6.29064C43.5445 6.12646 43.5402 5.29844 43.5414 5.07184L43.5405 4.90797C43.5395 4.73649 43.5308 4.55004 43.4534 4.42464C43.3127 4.3605 39.6586 4.39856 39.2372 4.42022C39.2346 5.54116 39.3017 7.62032 39.2227 8.65767C39.8819 8.77575 40.8548 8.72141 41.5473 8.7306C43.044 8.75047 44.5873 8.68661 46.0811 8.74152C48.0574 9.07225 47.7898 10.6883 47.7912 12.2335L47.7927 15.4922L47.7906 18.4807C47.7898 19.0263 47.8107 19.7717 47.7369 20.2956C47.6406 20.9796 46.8796 21.3767 46.4304 21.7683H36.3121C36.2027 21.6149 36.1134 21.6327 35.9738 21.5357C35.4292 21.1576 35.1345 20.9382 34.9687 20.2924C34.6904 19.5446 34.7691 16.1426 34.8167 15.1904C36.2579 15.1823 37.6992 15.1826 39.1404 15.1919C39.2267 15.6996 39.3276 16.8692 39.1858 17.3694C40.237 17.4621 42.3595 17.3889 43.5119 17.4104C43.5949 16.2224 43.5334 14.3279 43.5243 13.1009C41.5274 13.1176 39.5169 13.1161 37.5218 13.1107C36.7968 13.0849 36.0163 13.0516 35.4972 12.4773C35.0872 12.0412 34.8084 11.3444 34.8046 10.7424C34.786 7.85839 34.7995 4.96898 34.796 2.08477C34.7955 1.55989 35.288 0.746369 35.7131 0.426639C35.9237 0.268216 36.1019 0.195041 36.308 0H46.4198Z" fill="#C2BFB9"/>
<path d="M65.3009 0C65.3014 1.3829 65.3416 2.98525 65.2911 4.35024C64.7422 4.42078 64.1999 4.39321 63.6492 4.40102C61.3208 4.43408 58.966 4.34497 56.6399 4.4152C56.5222 5.10504 56.5466 7.89558 56.6075 8.64675C58.371 8.91365 61.2956 8.57726 63.155 8.75451C63.1619 9.33031 63.2364 12.8048 63.09 12.9914C62.4245 13.2103 57.8158 13.0747 56.6399 13.1346C56.5319 13.8193 56.5356 16.6111 56.6157 17.339C57.2675 17.4335 58.3879 17.3993 59.0861 17.3983L63.2055 17.393C63.7303 17.3926 64.8035 17.3652 65.2864 17.4568C65.3277 18.8678 65.3055 20.3516 65.3032 21.7683H52.2658C52.2263 20.3012 52.2575 18.6949 52.2587 17.2176L52.2575 3.29826C52.2575 2.27844 52.2221 0.986496 52.2826 0H65.3009Z" fill="#C2BFB9"/>
<path d="M69.709 15.9277C69.7095 16.8548 69.8347 20.9048 69.6665 21.5699L69.6585 21.7683H69.577C69.5012 19.154 69.5705 16.1626 69.5699 13.5128L69.5764 0H69.7169L69.709 15.9277Z" fill="#C2BFB9"/>
<path d="M81.279 0C82.0553 0.525496 82.5295 0.826058 82.7116 1.85212C82.8281 2.50704 82.7847 3.43871 82.7824 4.12614L82.7721 7.6237L82.783 21.7683H78.3371C78.309 21.0093 78.3119 20.1751 78.3274 19.4155C78.4256 14.4355 78.2023 9.37025 78.3424 4.39689C77.0235 4.39391 75.3819 4.36073 74.0784 4.41726C73.9687 6.15742 74.0513 8.78053 74.0518 10.5718L74.0583 21.7683H69.8049C69.7745 20.9202 69.7819 20.0099 69.7922 19.1574C69.8674 12.7851 69.6826 6.36743 69.8049 0H81.279Z" fill="#C2BFB9"/>
<path d="M98.6817 0C99.245 0.426776 100.072 0.950614 100.076 1.71305C100.082 2.69656 100.198 4.9897 100.101 5.87906C100.108 6.08496 100.138 6.32456 100.048 6.50617C99.3623 6.64648 96.5942 6.5453 95.7918 6.52743C95.7591 5.867 95.7855 5.10108 95.7614 4.40634C94.6379 4.39042 93.2518 4.37205 92.0758 4.40604L91.5862 4.42405L91.5157 4.78899L91.5234 4.90679C91.6004 6.42571 91.5939 17.0774 91.4593 17.3723C92.8277 17.4476 94.3649 17.3785 95.7505 17.3995C95.7999 16.7033 95.7632 15.8196 95.8556 15.1854C96.3432 15.1902 99.8544 15.1482 100.029 15.2796C100.318 16.4092 100.014 18.9486 100.065 20.2064C100.093 20.8885 98.912 21.4413 98.6817 21.7683H88.6216C88.5188 21.6378 87.6483 21.2069 87.4019 20.784C87.159 20.3662 87.1188 20.0112 87.1073 19.5173C87.0774 18.1858 87.0901 16.8285 87.0896 15.4969L87.0907 3.82617C87.089 1.84144 86.7898 1.18487 88.5549 0H98.6817Z" fill="#C2BFB9"/>
<path d="M117.6 4.31363C117.046 4.47618 109.809 4.45517 108.864 4.39453C108.808 5.53308 108.812 7.56345 108.857 8.71702C110.286 8.73247 111.715 8.73471 113.144 8.72351C113.709 8.72259 114.886 8.69262 115.41 8.77223C115.43 9.38599 115.515 12.6365 115.34 12.9828C114.816 13.2169 109.892 13.0772 108.863 13.1343C108.813 14.2134 108.806 16.3023 108.861 17.3847C110.832 17.4316 112.839 17.3716 114.811 17.3915C115.45 17.398 117.074 17.346 117.6 17.4671V21.7683H104.542C104.501 21.3743 104.548 19.6198 104.549 19.1359V4.65465C104.548 3.29906 104.486 1.29018 104.57 0H117.6V4.31363Z" fill="#C2BFB9"/>
<path d="M48.0177 10.366L48 10.4894C48.106 10.8938 48.0982 19.744 48.0038 20.0249C47.9833 20.0942 47.9735 20.1302 47.9409 20.1958L47.8721 20.1374C47.806 19.7354 47.8297 18.9655 47.8299 18.5327L47.8326 12.1983C47.832 11.8575 47.7421 10.4752 47.9374 10.2993L48.0177 10.366Z" fill="#C2BFB9"/>
<path d="M48.0186 1.90172C48.1014 2.28871 48.0969 6.19803 48.0053 6.44033C47.9765 6.42888 47.9313 6.38877 47.9046 6.3677C47.8471 5.53577 47.9104 4.49595 47.8922 3.6437C47.8847 3.29234 47.8659 2.12203 47.9079 1.84975L47.997 1.82702L48.0186 1.90172Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,18 @@
<svg width="114" height="23" viewBox="0 0 114 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M59.7261 19.7973C59.7261 21.2999 60.3252 22.0063 61.7621 22.0063C63.1991 22.0063 63.7905 21.2999 63.7905 19.7973V16.8824H64.7098V19.5511C64.7098 21.9979 63.6425 22.7617 61.7621 22.7617C59.8817 22.7617 58.8063 21.9979 58.8063 19.5511V16.8824H59.7261V19.7973Z" fill="#C2BFB9"/>
<path d="M88.2206 16.7512C90.0927 16.7513 91.3324 17.5068 91.349 18.6973H90.4045C90.4127 17.9584 89.5177 17.5146 88.2696 17.5146C86.9233 17.5146 86.2004 17.835 86.2004 18.4097C86.2004 19.8713 91.4883 18.3443 91.4883 20.98C91.4881 22.154 90.1824 22.7617 88.2943 22.7617C86.3566 22.7616 85.125 21.9733 85.1494 20.8155H86.0853C86.0771 21.5463 86.9724 21.9978 88.2287 21.9978C89.7391 21.9896 90.5603 21.6778 90.5604 21.0209C90.5604 19.3295 85.2561 20.9633 85.2558 18.5167C85.2558 17.359 86.4717 16.7512 88.2206 16.7512Z" fill="#C2BFB9"/>
<path d="M25.8672 22.3676L28.6832 16.8824H29.8165L26.7865 22.63H24.9393L21.9093 16.8824H23.0426L25.8672 22.3676Z" fill="#C2BFB9"/>
<path d="M37.4926 17.6463H33.174V19.3624H37.4189V20.0602H33.174V21.8666H37.4926V22.63H32.2541V16.8824H37.4926V17.6463Z" fill="#C2BFB9"/>
<path d="M46.0695 21.5134V16.8824H46.9889V22.63H46.0448L41.4633 18.0076V22.63H40.5435V16.8824H41.4794L46.0695 21.5134Z" fill="#C2BFB9"/>
<path d="M56.2693 17.6463H53.3463V22.63H52.427V17.6463H49.5121V16.8824H56.2693V17.6463Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.3161 16.8824C73.5067 16.8824 74.1638 17.5231 74.1638 18.4017C74.1637 19.3212 73.4244 19.8468 72.3161 19.8468H71.7081C73.0301 19.937 73.5643 20.758 74.5821 22.63H73.5805C72.5131 20.7169 72.2256 20.1258 71.1338 20.1258H68.7694L68.777 22.63H67.8577V16.8824H72.3161ZM68.7694 19.3376H72.0285C72.8987 19.3376 73.2439 19.0175 73.244 18.5495C73.244 17.9255 72.8827 17.6544 72.0285 17.6544H68.7608L68.7694 19.3376Z" fill="#C2BFB9"/>
<path d="M82.4683 17.6463H78.1492V19.3624H82.3942V20.0602H78.1492V21.8666H82.4683V22.63H77.2299V16.8824H82.4683V17.6463Z" fill="#C2BFB9"/>
<path d="M6.82474 0C10.6948 4.35571e-05 12.5148 1.24639 12.5148 1.24639V3.24338C12.5148 3.24338 10.6948 1.65668 6.9683 1.65663C4.07267 1.65663 2.17477 2.33495 2.17477 3.61749C2.17519 7.145 13.5522 3.35015 13.5525 9.15639C13.5525 11.688 10.9865 13.041 6.95119 13.041C2.69336 13.041 0.0167388 11.2525 0 11.2413V9.33892C0 9.33892 3.01305 11.2367 6.75677 11.2385C9.99295 11.2385 11.5455 10.7091 11.5455 9.2486C11.5453 5.47073 0.14838 9.46271 0.148312 3.86753C0.148312 1.33587 3.03945 0 6.82474 0Z" fill="#C2BFB9"/>
<path d="M113.4 1.95088H103.178V5.3516H111.851V7.061H103.178V11.1101H113.4V12.7677H101.186V0.293297H113.4V1.95088Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.5959 0.293297C44.1736 0.293325 45.7748 1.89222 45.7748 3.82142C45.7721 6.16157 43.9928 7.33195 41.5931 7.33195H35.2204V12.7667H33.2476V0.293297H41.5959ZM35.2175 5.62256H41.1476C42.9965 5.62256 43.7788 5.08577 43.7788 3.80954C43.7796 2.53432 42.9973 1.96799 41.1476 1.96799H35.2175V5.62256Z" fill="#C2BFB9"/>
<path d="M65.2223 5.34162H74.1286V0.284741H76.1208V12.7577H74.1286V6.99778H65.2223V12.7577H63.2295V0.284741H65.2223V5.34162Z" fill="#C2BFB9"/>
<path d="M81.6388 12.7577H79.6466V0.284741H81.6388V12.7577Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.0372 0.270955C96.6238 0.270955 98.0511 1.66293 98.0511 3.57138C98.051 5.56913 96.3736 6.71066 94.0372 6.71066L92.7171 6.72872V6.7639C95.589 6.95987 96.7305 8.69061 98.9424 12.7577H96.7125C94.3941 8.60131 93.5165 7.31681 91.1617 7.31674H87.1444L87.1611 12.7577H85.1627V0.270955H94.0372ZM87.1444 5.6045H93.4135C95.304 5.60445 96.0536 4.90875 96.0536 3.89177C96.0536 2.53619 95.2684 1.94711 93.4135 1.94707H87.1264L87.1444 5.6045Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.5869 0.284741C59.1648 0.284762 60.7658 1.88318 60.7658 3.81239C60.7633 6.15432 58.9847 7.32241 56.584 7.32245H50.2113V12.7568H48.2386V0.284741H56.5869ZM50.2113 5.61495H56.1415C57.9902 5.61495 58.7726 5.07773 58.7726 3.80241C58.7726 2.52713 57.9902 1.96087 56.1415 1.96086H50.2113V5.61495Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3638 12.7506H29.1187L27.2182 9.18919H18.8124L16.9076 12.7506H14.6458L17.5712 7.56773V7.55632H17.5783L21.6874 0.284741H24.3399L31.3638 12.7506ZM19.6847 7.55632H26.3468L23.0203 1.31675L19.6847 7.55632Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,19 @@
<svg width="122" height="23" viewBox="0 0 122 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.0498 2.08858C19.7947 2.09487 16.472 2.12329 13.2206 2.08944L19.5173 14.6881C20.6762 17.0087 21.9537 19.455 23.0556 21.7839C22.537 22.0518 21.7501 22.4192 21.2755 22.7035H21.1798C19.1216 18.6948 17.1673 14.5864 15.1189 10.569C14.2784 8.92062 13.4613 7.16352 12.5891 5.54081L12.586 22.7035H10.477L10.4773 11.7188C10.4775 9.68092 10.5065 7.56743 10.4584 5.53586L1.879 22.7035H1.78627C1.37028 22.4447 0.456336 22.0205 0 21.7985V21.6561C0.298159 21.359 1.90123 17.9578 2.20881 17.3451L9.82774 2.08739C6.60428 2.14206 3.23535 2.09182 0 2.0922V0H23.0537L23.0498 2.08858Z" fill="#C2BFB9"/>
<path d="M94.2751 16.0714C94.8228 16.0509 95.6125 16.4099 95.8079 16.974C95.859 17.1221 95.8464 17.3599 95.7353 17.4778C94.9441 17.4744 95.3563 17.2157 94.7999 16.742C94.4876 16.4761 93.7085 16.688 93.5059 16.9783C93.4182 17.1046 93.386 17.261 93.4152 17.4116C93.5741 18.2024 95.8371 17.9368 95.9443 19.0356C96.0388 20.0007 95.4829 20.4916 94.5864 20.6572C93.442 20.6721 92.873 20.2825 92.6806 19.144C93.7787 19.1232 93.1278 19.501 93.8006 19.9861C94.2084 20.2804 95.5531 19.9466 95.3158 19.1757C95.1965 18.7889 94.6712 18.6885 94.3185 18.5928C93.1658 18.3725 92.2635 17.7413 93.1024 16.5256C93.254 16.3062 93.6447 16.1662 93.9224 16.1013C94.0398 16.0869 94.1572 16.0769 94.2751 16.0714Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.03 16.125C88.0473 16.1227 89.6049 15.8493 90.0745 16.9793C90.2104 17.3105 90.2109 17.6818 90.076 18.0132C89.8962 18.4453 89.6243 18.6292 89.2175 18.7984L90.2889 20.6066C90.0818 20.6103 89.6828 20.6442 89.5274 20.5412L88.591 18.8506L87.6132 18.8521L87.6107 20.6034L87.03 20.6085V16.125ZM89.3618 16.9662C88.8692 16.6146 88.1866 16.7062 87.6137 16.7043V18.3109C88.2129 18.2761 88.895 18.3981 89.3535 18.0764C89.6078 17.7357 89.7646 17.2535 89.3618 16.9662Z" fill="#C2BFB9"/>
<path d="M74.945 16.1241C75.3893 16.1093 75.4195 16.0599 75.6924 16.4364C76.4349 17.4613 77.1779 18.4863 77.9208 19.511C77.8838 18.4414 77.912 17.2058 77.9116 16.1238C78.0699 16.1213 78.3398 16.0998 78.4733 16.1611C78.5576 17.3417 78.4913 19.3624 78.4899 20.6053C78.3773 20.6083 78.2209 20.6266 78.1123 20.6022C77.8794 20.5501 75.7956 17.5604 75.516 17.1475C75.5374 17.5867 75.5457 20.2559 75.4946 20.5576C75.3523 20.6301 75.1131 20.6075 74.9455 20.6044L74.945 16.1241Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M63.5806 16.1239C64.5886 16.1281 66.1603 15.8447 66.6154 16.9954C66.7479 17.3335 66.7405 17.7104 66.5949 18.0429C66.4161 18.4445 66.1481 18.6404 65.752 18.7915L66.8117 20.606C66.553 20.6109 66.344 20.618 66.0848 20.59C65.7954 20.0237 65.4349 19.428 65.1304 18.8499L64.1569 18.853L64.1564 20.6057L63.5801 20.6027L63.5806 16.1239ZM65.9084 18.0546C66.8258 16.6747 64.9638 16.6908 64.1555 16.7031L64.1545 18.3049C64.7752 18.285 65.4324 18.402 65.9084 18.0546Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.7406 16.1269C52.5533 16.1266 53.6914 15.9686 54.3052 16.4693C54.5941 16.7039 54.7754 17.0454 54.8085 17.4159C54.9055 18.4316 54.1839 18.9529 53.2568 19.029L52.3268 19.0155C52.3268 19.364 52.3555 20.2741 52.3 20.5673C52.1752 20.6234 51.8907 20.6075 51.7402 20.6076L51.7406 16.1269ZM53.953 16.9717C53.4839 16.6501 52.8641 16.7142 52.3263 16.7152L52.3268 18.435C52.8451 18.4403 53.5789 18.5221 53.973 18.1933C54.2682 17.8073 54.4061 17.2823 53.953 16.9717Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5497 16.1283C58.6715 16.1251 59.0277 16.1108 59.0525 16.1591C59.5056 17.049 60.6432 19.8474 60.9526 20.6056L60.3222 20.6069C60.1088 20.0731 59.9032 19.5364 59.7054 18.9968L59.2557 18.9939L57.9637 18.9952L57.3439 20.6039L56.7155 20.6089L58.5497 16.1283ZM58.807 16.8857C58.6043 17.3937 58.4026 17.9099 58.1931 18.4144L58.9634 18.4048L59.4506 18.4026C59.3634 18.1838 58.9078 16.9853 58.807 16.8857Z" fill="#C2BFB9"/>
<path d="M72.2907 16.1257L72.2888 16.7052L70.9475 16.7019V20.6017L70.3639 20.6083L70.3634 16.6974L69.0397 16.7083L69.0358 16.1253L72.2907 16.1257Z" fill="#C2BFB9"/>
<path d="M81.6099 16.1299C82.3422 16.0911 83.4564 16.1258 84.2169 16.1259L84.2179 16.7058L82.1906 16.7014V18.0281C82.558 18.0232 83.8613 17.985 84.155 18.0495C84.2237 18.1934 84.2023 18.4296 84.1989 18.5961L82.1941 18.5926C82.1756 19.0168 82.1921 19.5898 82.1911 20.0252C82.8376 19.9896 83.5504 20.0059 84.2033 20.0093L84.2038 20.6012L81.6114 20.6039L81.6099 16.1299Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.048 2.01512C101.256 1.6316 104.166 3.92364 104.545 7.13201C104.924 10.3404 102.628 13.2476 99.4195 13.6222C96.2171 13.9961 93.3168 11.7055 92.9382 8.50345C92.5602 5.30137 94.8467 2.39791 98.048 2.01512ZM98.6867 3.10704C96.0788 3.1376 93.9916 5.28081 94.0306 7.88864C94.0691 10.4965 96.2186 12.5769 98.8266 12.5301C101.423 12.4836 103.492 10.3453 103.453 7.74895C103.415 5.15252 101.284 3.07662 98.6867 3.10704Z" fill="#C2BFB9"/>
<path d="M56.334 2.23936L56.3354 7.25333C56.3374 8.25301 56.4163 9.6123 56.1474 10.5587C55.2582 13.6866 51.1053 14.6067 48.8433 12.3446C48.2999 11.7962 47.9137 11.1118 47.7254 10.3629C47.5005 9.45968 47.5651 7.80546 47.5654 6.80158L47.5691 2.24145L49.4849 2.24193L49.4795 8.18762C49.4776 9.16714 49.4898 10.3964 50.2425 11.0251C52.1474 12.6169 54.6429 11.2013 54.4202 8.74805C54.3774 8.27888 54.4144 7.46752 54.4139 7.02125L54.4149 2.23912L56.334 2.23936Z" fill="#C2BFB9"/>
<path d="M64.2963 2.24198C64.4346 2.60897 64.5974 2.98453 64.7523 3.34541C66.1905 6.68834 67.541 10.0817 68.9997 13.4146C68.6109 13.4031 68.1783 13.4146 67.7866 13.4166C67.3427 12.3577 66.9111 11.1524 66.4823 10.0707C65.6556 8.00572 64.8181 5.94491 63.9704 3.88842C63.9002 3.7518 63.8032 3.46465 63.7462 3.31049C63.4787 4.19435 62.7523 5.83983 62.3714 6.78616C61.4749 8.9828 60.6101 11.1921 59.7765 13.4133C59.3853 13.4134 58.9824 13.4254 58.5907 13.432L63.1572 2.24117L64.2963 2.24198Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.6301 2.24031C72.8651 2.25193 74.1007 2.22964 75.3357 2.23708C75.7045 2.23929 76.0758 2.23642 76.4417 2.28679C76.8475 2.33928 77.2431 2.45454 77.6134 2.62845C79.856 3.69654 80.1371 6.73413 78.1517 8.17892C77.7108 8.49963 77.3459 8.63518 76.8344 8.81442C77.685 10.1576 78.7934 12.0261 79.568 13.4145C79.1047 13.4104 78.6414 13.4155 78.1786 13.4297L75.6593 8.91114C74.6878 8.9184 73.7168 8.91904 72.7453 8.91319L72.7472 13.4206C72.3969 13.4055 71.9843 13.4234 71.6291 13.4305L71.6301 2.24031ZM77.6153 3.93647C76.8548 3.20318 75.7304 3.2863 74.7623 3.29212C74.0948 3.29205 73.4059 3.31248 72.7453 3.29174L72.7482 7.88555C73.3845 7.8645 74.0242 7.87144 74.6605 7.8787C75.6919 7.89049 76.8388 7.97424 77.6534 7.19857C78.54 6.24858 78.5785 4.86534 77.6153 3.93647Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.6478 2.23698C86.2003 2.23632 86.9369 2.21451 87.465 2.2867C87.8694 2.33854 88.2636 2.45389 88.6324 2.6285C90.8706 3.69892 91.1668 6.75605 89.1347 8.20509C88.7079 8.50973 88.3523 8.64067 87.8578 8.8129L90.6016 13.422C90.149 13.4092 89.6477 13.4173 89.1917 13.4154L88.9598 13.012C88.342 11.7801 87.3681 10.1562 86.6729 8.91086C85.7024 8.92129 84.7319 8.92301 83.7609 8.91614L83.7584 13.413C83.3945 13.411 83.0145 13.4228 82.6491 13.4286L82.6476 2.24102L85.6478 2.23698ZM89.3369 5.58386C89.2385 3.46873 87.4743 3.19518 85.7711 3.29355C85.1918 3.32701 84.3348 3.30925 83.7604 3.29512L83.7619 7.88569C84.4386 7.85368 85.165 7.87691 85.8442 7.87818C86.9671 7.88032 87.6848 7.96626 88.6105 7.253C89.0821 6.7754 89.3676 6.24411 89.3369 5.58386Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.6959 2.2405C38.0088 2.23962 41.1262 2.08233 42.1825 2.44885C43.071 2.75713 43.818 3.37736 44.2302 4.22851C44.6343 5.08458 44.6874 6.06475 44.3782 6.95954C44.0294 7.95825 43.3762 8.54797 42.4479 8.99488C43.2341 10.4347 44.1699 11.9925 44.9966 13.4126C44.2553 13.4093 43.5139 13.4123 42.7725 13.4216C42.6669 13.375 40.7449 9.78423 40.4665 9.2744C39.8363 9.29482 39.2057 9.29505 38.5756 9.27502L38.5742 13.4089C37.9485 13.4032 37.3228 13.4096 36.6973 13.4282L36.6959 2.2405ZM42.6604 5.66365C42.5015 3.61229 40.0352 4.19297 38.6077 4.11636C38.5295 4.47411 38.5793 6.90255 38.5753 7.43461C39.7802 7.38691 41.3058 7.66269 42.1911 6.95226C42.5306 6.55729 42.7023 6.20474 42.6604 5.66365Z" fill="#C2BFB9"/>
<path d="M107.485 2.24083L110.416 11.7167C111.247 9.42164 112.937 4.86395 113.836 2.33171C113.871 2.31265 113.901 2.29626 113.926 2.28261C113.993 2.27922 114.106 2.26986 114.165 2.29626C114.177 2.30151 114.235 2.33516 114.238 2.34103C115.238 4.99759 116.839 9.22764 117.773 11.7282C118.689 8.61534 119.661 5.32554 120.647 2.24083L121.8 2.2406V2.32586C121.717 2.4931 121.511 3.18662 121.446 3.39579L118.316 13.4202C118.001 13.409 117.609 13.4225 117.287 13.4247C117 12.5099 116.52 11.3168 116.181 10.4001L114.048 4.62407C113.126 7.44773 111.905 10.5943 110.885 13.4162L109.871 13.4258C109.707 12.9473 109.552 12.452 109.402 11.9688C108.394 8.72176 107.293 5.49167 106.305 2.23965L107.485 2.24083Z" fill="#C2BFB9"/>
<path d="M34.0608 2.2406L34.0592 4.08982L30.9074 4.08301L30.9068 13.4006L29.0442 13.4035L29.0456 4.07811L25.884 4.08896L25.8833 2.23907L34.0608 2.2406Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="37" height="48" viewBox="0 0 37 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37 48H23.3135C17.0346 47.9999 13.3532 43.0774 15.0801 37.0127L16.7461 31.1621C17.458 28.6708 15.5955 26.1868 13.0205 26.1865C12.1061 26.1865 11.4659 26.9844 11.2399 27.8705L9.74121 33.748C8.5757 38.3031 4.21923 42 0 42V6H9.18164C13.4008 6.00006 15.8743 9.69691 14.7139 14.252L14.0983 16.6643C13.497 19.0203 13.9592 22.0946 16.3906 22.0947C18.125 22.0946 19.6625 20.9366 20.1396 19.2529L22.4951 10.9873H22.5107C24.2377 4.92254 30.7286 1.98986e-06 37 0V48Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

View File

@@ -0,0 +1,3 @@
<svg width="62" height="94.14" viewBox="0 0 62 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.9346 0C33.456 0.000149153 39.6242 8.20368 36.7305 18.3115L33.9385 28.0635C32.7454 32.2159 35.8674 36.3555 40.1826 36.3555C42.9814 36.3555 45.4493 34.5653 46.3311 31.9268L47.7129 27.002C49.4225 20.9287 55.812 16 62 16V64H48.5342C42.3461 64 38.7182 59.0713 40.4199 52.998L40.8398 51.5L40.8301 51.4922C42.0104 47.3146 38.8756 43.1751 34.5352 43.1748C31.6287 43.1748 29.0515 45.1048 28.252 47.9111L24.3047 61.6885H24.2793C21.3855 71.7964 10.5089 80 0 80V0H22.9346Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#211927"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#211927"><path d="M3 5.548l7.546-1.03v7.287H3V5.548zm0 12.904l7.546 1.03v-7.177H3v6.147zm8.454 1.14L22 21v-8.695h-10.546v8.287zM11.454 4.408L22 3v8.695H11.454V4.408z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,4 @@
WEBVTT
00:00:00.000 --> 00:00:06.000
AI-generated video showcasing Grok Imagine image generation capabilities

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -0,0 +1,4 @@
WEBVTT
00:00:00.000 --> 00:00:05.000
AI-generated video showcasing Seedance 2.0 video generation capabilities

View File

@@ -0,0 +1,4 @@
WEBVTT
00:00:00.000 --> 00:00:05.000
AI-generated video showcasing Wan 2.2 image-to-video generation capabilities

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -0,0 +1,110 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 900" fill="none">
<!-- Background geometric lines -->
<g stroke="#49378B" stroke-width="1.5" fill="none" opacity="0.4">
<!-- Outer hexagonal frame layers -->
<path d="M400 80 L600 200 L600 440 L400 560 L200 440 L200 200 Z" />
<path d="M400 120 L570 220 L570 420 L400 520 L230 420 L230 220 Z" />
<!-- Connector lines going up -->
<line x1="300" y1="160" x2="300" y2="60" />
<line x1="400" y1="120" x2="400" y2="20" />
<line x1="500" y1="160" x2="500" y2="60" />
<!-- Bottom platform layers -->
<path d="M250 520 L550 520 L600 560 L600 600 L400 700 L200 600 L200 560 Z" opacity="0.3" />
<path d="M280 620 L520 620 L560 650 L560 680 L400 760 L240 680 L240 650 Z" opacity="0.2" />
<path d="M320 700 L480 700 L510 720 L510 740 L400 800 L290 740 L290 720 Z" opacity="0.15" />
</g>
<!-- 3D Isometric cube cluster -->
<g transform="translate(400, 380)">
<!-- Back layer cubes (purple/dark) -->
<!-- Top back -->
<g transform="translate(0, -100)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Middle row - left back -->
<g transform="translate(-70, -55)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Middle row - right back -->
<g transform="translate(70, -55)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Yellow accent cubes - front facing -->
<!-- Top -->
<g transform="translate(0, -65)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Middle left yellow -->
<g transform="translate(-70, -20)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Middle right yellow -->
<g transform="translate(70, -20)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Center purple -->
<g transform="translate(0, -20)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Bottom row -->
<g transform="translate(-70, 25)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<g transform="translate(70, 25)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
</g>
<!-- Front bottom yellow -->
<g transform="translate(0, 25)">
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
</g>
<!-- Outer corner yellow accents -->
<g transform="translate(-105, 5)">
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
<polygon points="0,-2 20,-12 20,12 0,25" fill="#d4e04e" />
</g>
<g transform="translate(105, 5)">
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
<polygon points="0,-2 -20,-12 -20,12 0,25" fill="#e0ec50" />
</g>
<g transform="translate(0, -135)">
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
</g>
</g>
<!-- Bottom arrow/chevron shape -->
<path d="M340 780 L400 820 L460 780 L460 850 L400 890 L340 850 Z" fill="#211927" stroke="#49378B" stroke-width="1" opacity="0.5" />
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div
class="bg-transparency-white-t4 rounded-5xl mx-auto mt-20 flex flex-col gap-12 p-2 lg:flex-row lg:items-stretch lg:gap-8"
>
<!-- Team photo -->
<div class="aspect-video w-full overflow-hidden rounded-4xl lg:w-1/2">
<img
src="/images/about/team.webp"
alt="Comfy team"
class="size-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<!-- Join text -->
<div class="flex flex-col justify-between p-6 lg:w-1/2">
<div>
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.careers.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-4 text-3xl font-light lg:text-5xl"
>
{{ t('about.careers.heading', locale) }}
</h2>
</div>
<div>
<BrandButton
:href="locale === 'zh-CN' ? '/zh-CN/careers' : '/careers'"
:label="t('about.careers.cta', locale)"
variant="solid"
class-name="mt-8 self-start rounded-2xl"
/>
<p class="text-primary-warm-gray mt-6 text-sm">
{{ t('about.careers.noRole', locale) }}
<a
href="mailto:hiring@comfy.org"
class="text-primary-comfy-yellow hover:underline"
>
hiring@comfy.org
</a>
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const sectionRef = ref<HTMLElement>()
const logoRef = ref<HTMLElement>()
const labelRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
const bodyRef = ref<HTMLElement>()
const ctaRef = ref<HTMLElement>()
const videoRef = ref<HTMLElement>()
useHeroAnimation({
section: sectionRef,
textEls: [labelRef, headingRef, bodyRef, ctaRef],
logo: logoRef,
video: videoRef
})
</script>
<template>
<section ref="sectionRef" class="pt-12 lg:pt-20">
<div
class="flex flex-col items-center text-center lg:flex-row lg:items-start lg:text-left"
>
<!-- Graphic -->
<div
ref="logoRef"
class="order-2 mt-8 w-full lg:order-1 lg:mt-0 lg:w-5/12"
>
<img
src="/images/about/c.webp"
alt="Comfy 3D logo"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>
<!-- Text -->
<div
class="order-1 flex flex-col items-center lg:order-2 lg:w-7/12 lg:items-start lg:pt-24 lg:pl-12"
>
<span
ref="labelRef"
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.hero.label', locale) }}
</span>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
>
{{ t('about.hero.heading', locale) }}
</h1>
<p ref="bodyRef" class="text-primary-warm-gray mt-6 max-w-sm text-base">
{{ t('about.hero.body', locale) }}
</p>
<div ref="ctaRef" class="mt-8">
<BrandButton
:href="locale === 'zh-CN' ? '/zh-CN/careers' : '/careers'"
:label="t('about.hero.cta', locale)"
variant="outline"
class-name="rounded-full"
/>
</div>
</div>
</div>
<!-- Video overlapping the hero graphic -->
<div ref="videoRef" class="-mt-16 px-20 pb-40 lg:-mt-72">
<VideoPlayer :locale />
</div>
</section>
</template>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import NodeBadge from '../common/NodeBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type TranslationKey = Parameters<typeof t>[0]
const values: {
segments: Array<{ text: string }>
bodyKey: TranslationKey
}[] = [
{
segments: [{ text: 'SHIP' }, { text: 'IT' }],
bodyKey: 'about.values.card1.body'
},
{
segments: [{ text: 'SHARE' }, { text: 'IT' }],
bodyKey: 'about.values.card2.body'
},
{
segments: [{ text: 'OPEN-SOURCE' }, { text: 'IT' }],
bodyKey: 'about.values.card3.body'
},
{
segments: [{ text: 'RESPECT' }, { text: 'THE CRAFT' }],
bodyKey: 'about.values.card4.body'
}
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.values.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.values.headingBefore', locale)
}}<span class="text-primary-comfy-yellow">{{
t('about.values.headingHighlight', locale)
}}</span
>{{ t('about.values.headingAfter', locale) }}
</h2>
</div>
<div class="mx-auto mt-16 max-w-5xl">
<!-- Desktop layout -->
<div class="hidden lg:block">
<!-- Row 1: SHIP IT + SHARE IT -->
<div class="flex items-center gap-0">
<div
class="border-primary-comfy-yellow flex-1 rounded-3xl border p-8"
>
<NodeBadge
:segments="values[0].segments"
segment-class="lg:py-3"
text-class="text-2xl lg:text-3xl"
/>
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(values[0].bodyKey, locale) }}
</p>
</div>
<img
src="/icons/node-link.svg"
alt=""
class="shrink-0"
aria-hidden="true"
/>
<div
class="border-primary-comfy-yellow flex-1 rounded-3xl border p-8"
>
<NodeBadge
:segments="values[1].segments"
segment-class="lg:py-3"
text-class="text-2xl lg:text-3xl"
/>
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(values[1].bodyKey, locale) }}
</p>
</div>
</div>
<!-- Connector line -->
<div class="flex justify-end pr-12">
<img
src="/icons/node-link.svg"
alt=""
class="-my-1.5 rotate-90"
aria-hidden="true"
/>
</div>
<!-- Row 2: OPEN-SOURCE IT -->
<div class="border-primary-comfy-yellow rounded-3xl border p-8">
<NodeBadge
:segments="values[2].segments"
segment-class="px-3"
text-class="text-2xl lg:text-3xl"
/>
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(values[2].bodyKey, locale) }}
</p>
</div>
<!-- Connector line -->
<div class="flex justify-start pl-24">
<img
src="/icons/node-link.svg"
alt=""
class="-my-1.5 rotate-90"
aria-hidden="true"
/>
</div>
<!-- Row 3: RESPECT THE CRAFT -->
<div class="border-primary-comfy-yellow rounded-3xl border p-8">
<NodeBadge
:segments="values[3].segments"
segment-class="px-3"
text-class="text-2xl lg:text-3xl"
/>
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(values[3].bodyKey, locale) }}
</p>
</div>
</div>
<!-- Mobile: stacked cards -->
<div class="flex flex-col items-center lg:hidden">
<template v-for="(value, i) in values" :key="value.segments[0].text">
<div
v-if="i > 0"
class="flex w-full"
:class="i % 2 === 1 ? 'justify-end pr-16' : 'justify-start pl-16'"
>
<img
src="/icons/node-link.svg"
alt=""
class="-my-1 w-3 shrink-0 rotate-90"
aria-hidden="true"
/>
</div>
<div
class="border-primary-comfy-yellow w-full rounded-3xl border p-8"
>
<NodeBadge
:segments="value.segments"
segment-class="px-3"
text-class="text-2xl lg:text-3xl"
/>
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(value.bodyKey, locale) }}
</p>
</div>
</template>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const investors = [
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
{ name: 'SAPPHIRE VENTURES', icon: '/icons/investors/sapphire-ventures.svg' },
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.story.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.story.headingBefore', locale)
}}<span class="text-primary-comfy-yellow">{{
t('about.story.headingHighlight', locale)
}}</span
>{{ t('about.story.headingAfter', locale) }}
</h2>
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
{{ t('about.story.body', locale) }}
</p>
</div>
<!-- Investor card -->
<div
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
>
<div class="inline-flex items-center">
<!-- OUR badge (shorter) -->
<div class="relative z-10 flex h-9 items-center">
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
>
OUR
</span>
</div>
<!-- Union connector (overlaps both badges to eliminate seams) -->
<img
src="/icons/node-union-2size-reverse.svg"
alt=""
class="relative z-20 -mx-px h-12 w-auto"
/>
<!-- INVESTORS badge (taller) -->
<div class="relative z-10 flex h-12 items-center">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
>
INVESTORS
</span>
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
</div>
</div>
<p
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
>
{{ t('about.story.investorsBody', locale) }}
</p>
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
<div
v-for="investor in investors"
:key="investor.name"
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
>
<img
:src="investor.icon"
:alt="investor.name"
class="max-h-8 w-auto"
/>
</div>
</div>
</div>
<!-- Quote card -->
<div
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
>
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
{{ t('about.quote.text', locale) }}
</p>
<p
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
>
{{ t('about.quote.attribution', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { onMounted, ref } from 'vue'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type TranslationKey = Parameters<typeof t>[0]
const reasons: TranslationKey[] = [
'about.careers.reason1',
'about.careers.reason2',
'about.careers.reason3',
'about.careers.reason4'
]
const containerRef = ref<HTMLElement>()
const ifYouDotRef = ref<HTMLElement>()
const reasonDots = ref<HTMLElement[]>([])
const reasonOutputDotRef = ref<HTMLElement>()
const comfyDotRef = ref<HTMLElement>()
const wirePaths = ref<string[]>([])
const comfyWirePath = ref('')
const mobileContainerRef = ref<HTMLElement>()
const mobileIfYouDotRef = ref<HTMLElement>()
const mobileReasonDots = ref<HTMLElement[]>([])
const mobileOutputDotRef = ref<HTMLElement>()
const mobileComfyDotRef = ref<HTMLElement>()
const mobileWirePaths = ref<string[]>([])
const mobileComfyWirePath = ref('')
function center(el: HTMLElement, container: DOMRect) {
const r = el.getBoundingClientRect()
return {
x: r.left + r.width / 2 - container.left,
y: r.top + r.height / 2 - container.top
}
}
function computeWires() {
const c = containerRef.value
const dot = ifYouDotRef.value
if (!c || !dot) return
const cRect = c.getBoundingClientRect()
const s = center(dot, cRect)
wirePaths.value = reasonDots.value.map((el) => {
const e = center(el, cRect)
const midX = s.x + (e.x - s.x) * 0.45
return `M${s.x},${s.y} C${midX},${s.y} ${midX},${e.y} ${e.x},${e.y}`
})
const outputDot = reasonOutputDotRef.value
const comfyDot = comfyDotRef.value
if (outputDot && comfyDot) {
const s2 = center(outputDot, cRect)
const e2 = center(comfyDot, cRect)
const midX = s2.x + (e2.x - s2.x) * 0.5
comfyWirePath.value = `M${s2.x},${s2.y} C${midX},${s2.y} ${midX},${e2.y} ${e2.x},${e2.y}`
}
}
function computeMobileWires() {
const c = mobileContainerRef.value
const dot = mobileIfYouDotRef.value
if (!c || !dot) return
const cRect = c.getBoundingClientRect()
const s = center(dot, cRect)
mobileWirePaths.value = mobileReasonDots.value.map((el, i) => {
const e = center(el, cRect)
const spread = (i + 1) * 14
return `M${s.x},${s.y} C${s.x + spread},${s.y + 40} ${e.x + spread},${e.y - 40} ${e.x},${e.y}`
})
const outputDot = mobileOutputDotRef.value
const comfyDot = mobileComfyDotRef.value
if (outputDot && comfyDot) {
const s2 = center(outputDot, cRect)
const e2 = center(comfyDot, cRect)
const midY = s2.y + (e2.y - s2.y) * 0.5
mobileComfyWirePath.value = `M${s2.x},${s2.y} C${s2.x},${midY} ${e2.x},${midY} ${e2.x},${e2.y}`
}
}
onMounted(() => {
requestAnimationFrame(() => {
computeWires()
computeMobileWires()
})
})
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<!-- Desktop layout -->
<div ref="containerRef" class="relative mx-auto hidden max-w-6xl lg:block">
<!-- SVG wires overlay -->
<svg
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
>
<path
v-for="(d, i) in wirePaths"
:key="'wire-' + i"
:d="d"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
<path
v-if="comfyWirePath"
:d="comfyWirePath"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
</svg>
<div class="flex items-start gap-8">
<!-- Left column: Why + IF YOU -->
<div class="flex w-64 shrink-0 flex-col gap-3">
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
<p class="text-primary-warm-white text-2xl font-light">
{{ t('about.careers.whyTitleBefore', locale) }}
<br />
<span
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>{{ t('about.careers.whyTitleAfter', locale) }}
</p>
</div>
<div
class="flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
>
<span
class="text-primary-warm-white text-xs font-bold tracking-wider"
>
{{ t('about.careers.whyLabel', locale) }}
</span>
<span
ref="ifYouDotRef"
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
/>
</div>
</div>
<!-- Center column: Reasons card -->
<div class="relative flex-1">
<span
ref="reasonOutputDotRef"
class="bg-primary-comfy-yellow absolute top-1/3 right-0 z-20 size-3 translate-x-1/2 -translate-y-1/2 rounded-full"
/>
<div class="rounded-3xl border border-white/10 bg-white/5 px-10 py-8">
<div class="flex flex-col gap-6">
<div
v-for="reason in reasons"
:key="reason"
class="flex items-start gap-3"
>
<span
:ref="
(el) => {
if (el) reasonDots.push(el as HTMLElement)
}
"
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
/>
<p class="text-primary-warm-white text-base">
{{ t(reason, locale) }}
</p>
</div>
</div>
</div>
</div>
<!-- Right column: Comfy logo card -->
<div
class="w-64 shrink-0 rounded-3xl border border-white/10 bg-white/5 p-6"
>
<span
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
>
<span
ref="comfyDotRef"
class="bg-primary-comfy-ink relative z-10 size-1.5 rounded-full"
/>
<span
class="bg-primary-comfy-ink h-4 w-20"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>
</span>
<img
src="/images/about/c-logo.webp"
alt="Comfy logo"
class="mt-6 w-full"
/>
</div>
</div>
</div>
<!-- Mobile layout -->
<div ref="mobileContainerRef" class="relative mx-auto max-w-6xl lg:hidden">
<svg
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
>
<path
v-for="(d, i) in mobileWirePaths"
:key="'m-wire-' + i"
:d="d"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
<path
v-if="mobileComfyWirePath"
:d="mobileComfyWirePath"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
</svg>
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
<p class="text-primary-warm-white text-2xl font-light">
{{ t('about.careers.whyTitleBefore', locale) }}
<br />
<span
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>{{ t('about.careers.whyTitleAfter', locale) }}
</p>
</div>
<div
class="mt-3 flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
>
<span class="text-primary-warm-white text-xs font-bold tracking-wider">
{{ t('about.careers.whyLabel', locale) }}
</span>
<span
ref="mobileIfYouDotRef"
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
/>
</div>
<div
class="relative mt-12 rounded-3xl border border-white/10 bg-white/5 p-8"
>
<span
ref="mobileOutputDotRef"
class="bg-primary-comfy-yellow absolute right-1/3 bottom-0 z-20 size-3 translate-y-1/2 rounded-full"
/>
<div class="flex flex-col gap-6">
<div
v-for="reason in reasons"
:key="reason"
class="flex items-start justify-between gap-4"
>
<p class="text-primary-warm-white text-base">
{{ t(reason, locale) }}
</p>
<span
:ref="
(el) => {
if (el) mobileReasonDots.push(el as HTMLElement)
}
"
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
/>
</div>
</div>
</div>
<div class="mt-12 rounded-3xl border border-white/10 bg-white/5 p-6">
<span
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
>
<span
ref="mobileComfyDotRef"
class="bg-primary-comfy-ink size-1.5 rounded-full"
/>
<span
class="bg-primary-comfy-ink h-4 w-20"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>
</span>
<img
src="/images/about/c-logo.webp"
alt="Comfy logo"
class="mt-6 w-full max-w-xs"
/>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 pt-20 pb-16 md:pt-28 md:pb-24">
<div class="mx-auto max-w-4xl text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('careers.hero.label', locale) }}
</span>
<h1
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line md:text-6xl"
>
{{ t('careers.hero.heading', locale) }}
</h1>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mx-auto mt-12 max-w-3xl p-2 md:mt-16"
>
<img
src="/images/careers/hero.webp"
alt="Comfy team"
class="w-full rounded-4xl object-cover"
/>
<div class="text-primary-comfy-canvas space-y-6 p-8 text-base/relaxed">
<p>{{ t('careers.hero.body1', locale) }}</p>
<p>{{ t('careers.hero.body2', locale) }}</p>
<p>{{ t('careers.hero.body3', locale) }}</p>
<p>{{ t('careers.hero.body4', locale) }}</p>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CategoryNav from '../common/CategoryNav.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeCategory = ref('all')
interface Role {
title: string
department: string
location: string
id: string
}
interface Department {
name: string
key: string
roles: Role[]
}
const departments: Department[] = [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
id: 'abc787b9-ad85-421c-8218-debd23bea096'
},
{
title: 'Software Engineer',
department: 'Engineering',
location: 'San Francisco',
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
},
{
title: 'Product Manager',
department: 'Engineering',
location: 'London, UK',
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
},
{
title: 'Tech Lead Manager, Frontend',
department: 'Engineering',
location: 'San Francisco',
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
}
]
},
{
name: 'DESIGN',
key: 'design',
roles: [
{
title: 'Creative Director',
department: 'Design',
location: 'San Francisco',
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
},
{
title: 'Graphic Designer',
department: 'Design',
location: 'London, UK',
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
},
{
title: 'Freelance Motion Designer',
department: 'Design',
location: 'Remote',
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
}
]
},
{
name: 'MARKETING',
key: 'marketing',
roles: [
{
title: 'Lifecycle Growth Marketer',
department: 'Marketing',
location: 'San Francisco',
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
},
{
title: 'Graphic Designer',
department: 'Marketing',
location: 'London, UK',
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
}
]
}
]
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...departments.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? departments
: departments.filter((d) => d.key === activeCategory.value)
)
</script>
<template>
<section class="px-6 py-20 md:px-20 md:py-32">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<!-- Left sidebar -->
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
>
<h2
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl"
>
{{ t('careers.roles.heading', locale) }}
</h2>
<CategoryNav
v-model="activeCategory"
:categories="categories"
class="mt-4"
/>
</div>
</div>
<!-- Role listings -->
<div class="min-w-0 flex-1">
<div
v-for="dept in filteredDepartments"
:key="dept.key"
class="mb-12 last:mb-0"
>
<h3
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest"
>
{{ dept.name }}
</h3>
<a
v-for="role in dept.roles"
:key="role.id"
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
>
<div class="min-w-0">
<span
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
>
{{ role.title }}
</span>
<span class="text-primary-warm-gray ml-3 text-sm">
{{ role.department }}
</span>
</div>
<div class="ml-4 flex shrink-0 items-center gap-3">
<span class="text-primary-warm-gray text-sm">
{{ role.location }}
</span>
<img
src="/icons/arrow-up-right.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</div>
</a>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const photos = [
{ src: '/images/careers/team0.webp', alt: 'Team dinner' },
{ src: '/images/careers/team1.webp', alt: 'Team working' },
{ src: '/images/careers/team2.webp', alt: 'Team offsite' },
{ src: '/images/careers/team3.webp', alt: 'Team on a boat' }
]
const loopedPhotos = [...photos, ...photos, ...photos]
const scrollRef = ref<HTMLElement>()
function onScroll() {
const el = scrollRef.value
if (!el) return
const third = el.scrollWidth / 3
const maxScroll = el.scrollWidth - el.clientWidth
if (el.scrollLeft >= maxScroll - 1) {
el.scrollLeft -= third
} else if (el.scrollLeft <= 1) {
el.scrollLeft += third
}
}
onMounted(() => {
const el = scrollRef.value
if (el) {
el.scrollLeft = el.scrollWidth / 3
}
})
</script>
<template>
<section class="py-12 md:py-24">
<div
ref="scrollRef"
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
style="scrollbar-width: none"
@scroll="onScroll"
>
<div
v-for="(photo, i) in loopedPhotos"
:key="i"
class="aspect-3/4 h-64 shrink-0 md:h-96"
>
<img
:src="photo.src"
:alt="photo.alt"
class="size-full rounded-2xl object-cover md:rounded-3xl"
loading="lazy"
decoding="async"
/>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,305 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { onMounted, ref } from 'vue'
import { t } from '../../i18n/translations'
type TranslationKey = Parameters<typeof t>[0]
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasons: TranslationKey[] = [
'careers.whyJoin.reason1',
'careers.whyJoin.reason2',
'careers.whyJoin.reason3',
'careers.whyJoin.reason4',
'careers.whyJoin.reason5'
]
const containerRef = ref<HTMLElement>()
const ifYouDotRef = ref<HTMLElement>()
const reasonDots = ref<HTMLElement[]>([])
const reasonOutputDotRef = ref<HTMLElement>()
const comfyDotRef = ref<HTMLElement>()
const wirePaths = ref<string[]>([])
const comfyWirePath = ref('')
const mobileContainerRef = ref<HTMLElement>()
const mobileIfYouDotRef = ref<HTMLElement>()
const mobileReasonDots = ref<HTMLElement[]>([])
const mobileOutputDotRef = ref<HTMLElement>()
const mobileComfyDotRef = ref<HTMLElement>()
const mobileWirePaths = ref<string[]>([])
const mobileComfyWirePath = ref('')
function center(el: HTMLElement, container: DOMRect) {
const r = el.getBoundingClientRect()
return {
x: r.left + r.width / 2 - container.left,
y: r.top + r.height / 2 - container.top
}
}
function computeWires() {
const c = containerRef.value
const dot = ifYouDotRef.value
if (!c || !dot) return
const cRect = c.getBoundingClientRect()
const s = center(dot, cRect)
wirePaths.value = reasonDots.value.map((el) => {
const e = center(el, cRect)
const midX = s.x + (e.x - s.x) * 0.45
return `M${s.x},${s.y} C${midX},${s.y} ${midX},${e.y} ${e.x},${e.y}`
})
const outputDot = reasonOutputDotRef.value
const comfyDot = comfyDotRef.value
if (outputDot && comfyDot) {
const s2 = center(outputDot, cRect)
const e2 = center(comfyDot, cRect)
const midX = s2.x + (e2.x - s2.x) * 0.5
comfyWirePath.value = `M${s2.x},${s2.y} C${midX},${s2.y} ${midX},${e2.y} ${e2.x},${e2.y}`
}
}
function computeMobileWires() {
const c = mobileContainerRef.value
const dot = mobileIfYouDotRef.value
if (!c || !dot) return
const cRect = c.getBoundingClientRect()
const s = center(dot, cRect)
mobileWirePaths.value = mobileReasonDots.value.map((el, i) => {
const e = center(el, cRect)
const spread = (i + 1) * 14
return `M${s.x},${s.y} C${s.x + spread},${s.y + 40} ${e.x + spread},${e.y - 40} ${e.x},${e.y}`
})
const outputDot = mobileOutputDotRef.value
const comfyDot = mobileComfyDotRef.value
if (outputDot && comfyDot) {
const s2 = center(outputDot, cRect)
const e2 = center(comfyDot, cRect)
const midY = s2.y + (e2.y - s2.y) * 0.5
mobileComfyWirePath.value = `M${s2.x},${s2.y} C${s2.x},${midY} ${e2.x},${midY} ${e2.x},${e2.y}`
}
}
onMounted(() => {
requestAnimationFrame(() => {
computeWires()
computeMobileWires()
})
})
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<!-- Desktop layout -->
<div ref="containerRef" class="relative mx-auto hidden max-w-6xl lg:block">
<!-- SVG wires overlay -->
<svg
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
>
<path
v-for="(d, i) in wirePaths"
:key="'wire-' + i"
:d="d"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
<path
v-if="comfyWirePath"
:d="comfyWirePath"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
</svg>
<div class="flex items-start gap-8">
<!-- Left column: Why + IF YOU -->
<div class="flex w-64 shrink-0 flex-col gap-3">
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
<p class="text-primary-warm-white text-2xl font-light">
{{ t('about.careers.whyTitleBefore', locale) }}
<br />
<span
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>{{ t('about.careers.whyTitleAfter', locale) }}
</p>
</div>
<div
class="flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
>
<span
class="text-primary-warm-white text-xs font-bold tracking-wider"
>
{{ t('about.careers.whyLabel', locale) }}
</span>
<span
ref="ifYouDotRef"
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
/>
</div>
</div>
<!-- Center column: Reasons card -->
<div class="relative flex-1">
<span
ref="reasonOutputDotRef"
class="bg-primary-comfy-yellow absolute top-1/3 right-0 z-20 size-3 translate-x-1/2 -translate-y-1/2 rounded-full"
/>
<div class="rounded-3xl border border-white/10 bg-white/5 px-10 py-8">
<div class="flex flex-col gap-6">
<div
v-for="reason in reasons"
:key="reason"
class="flex items-start gap-3"
>
<span
:ref="
(el) => {
if (el) reasonDots.push(el as HTMLElement)
}
"
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
/>
<p class="text-primary-warm-white text-base">
{{ t(reason, locale) }}
</p>
</div>
</div>
</div>
</div>
<!-- Right column: Team photo card -->
<div
class="w-64 shrink-0 rounded-3xl border border-white/10 bg-white/5 p-2"
>
<span
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
>
<span
ref="comfyDotRef"
class="bg-primary-comfy-ink size-1.5 rounded-full"
/>
<span
class="bg-primary-comfy-ink h-4 w-20"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>
</span>
<img
src="/images/about/team.webp"
alt="Comfy team"
class="mt-2 w-full rounded-2xl object-cover"
loading="lazy"
decoding="async"
/>
</div>
</div>
</div>
<!-- Mobile layout -->
<div ref="mobileContainerRef" class="relative mx-auto max-w-6xl lg:hidden">
<svg
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
>
<path
v-for="(d, i) in mobileWirePaths"
:key="'m-wire-' + i"
:d="d"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
<path
v-if="mobileComfyWirePath"
:d="mobileComfyWirePath"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
</svg>
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
<p class="text-primary-warm-white text-2xl font-light">
{{ t('about.careers.whyTitleBefore', locale) }}
<br />
<span
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>{{ t('about.careers.whyTitleAfter', locale) }}
</p>
</div>
<div
class="mt-3 flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
>
<span class="text-primary-warm-white text-xs font-bold tracking-wider">
{{ t('about.careers.whyLabel', locale) }}
</span>
<span
ref="mobileIfYouDotRef"
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
/>
</div>
<div
class="relative mt-12 rounded-3xl border border-white/10 bg-white/5 p-8"
>
<span
ref="mobileOutputDotRef"
class="bg-primary-comfy-yellow absolute right-1/3 bottom-0 z-20 size-3 translate-y-1/2 rounded-full"
/>
<div class="flex flex-col gap-6">
<div
v-for="reason in reasons"
:key="reason"
class="flex items-start justify-between gap-4"
>
<p class="text-primary-warm-white text-base">
{{ t(reason, locale) }}
</p>
<span
:ref="
(el) => {
if (el) mobileReasonDots.push(el as HTMLElement)
}
"
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
/>
</div>
</div>
</div>
<div class="mt-12 rounded-3xl border border-white/10 bg-white/5 p-2">
<span
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
>
<span
ref="mobileComfyDotRef"
class="bg-primary-comfy-ink size-1.5 rounded-full"
/>
<span
class="bg-primary-comfy-ink h-4 w-20"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>
</span>
<img
src="/images/about/team.webp"
alt="Comfy team"
class="mt-2 w-full rounded-2xl object-cover"
loading="lazy"
decoding="async"
/>
</div>
</div>
</section>
</template>

View File

@@ -15,6 +15,10 @@ const meta: Meta<typeof BrandButton> = {
variant: {
control: { type: 'select' },
options: ['solid', 'outline']
},
size: {
control: { type: 'select' },
options: ['sm', 'lg']
}
},
args: {
@@ -38,13 +42,33 @@ export const Outline: Story = {
}
}
export const LargeSolid: Story = {
args: {
variant: 'solid',
size: 'lg'
}
}
export const LargeOutline: Story = {
args: {
variant: 'outline',
size: 'lg'
}
}
export const AllVariants: Story = {
render: () => ({
components: { BrandButton },
template: `
<div class="flex gap-4">
<BrandButton href="#" label="SOLID BUTTON" variant="solid" />
<BrandButton href="#" label="OUTLINE BUTTON" variant="outline" />
<div class="flex flex-col gap-4">
<div class="flex gap-4 items-center">
<BrandButton href="#" label="SOLID SM" variant="solid" size="sm" />
<BrandButton href="#" label="OUTLINE SM" variant="outline" size="sm" />
</div>
<div class="flex gap-4 items-center">
<BrandButton href="#" label="SOLID LG" variant="solid" size="lg" />
<BrandButton href="#" label="OUTLINE LG" variant="outline" size="lg" />
</div>
</div>
`
})

View File

@@ -5,13 +5,20 @@ const {
href,
label,
variant = 'solid',
size = 'sm',
className = ''
} = defineProps<{
href: string
label: string
label?: string
variant?: 'solid' | 'outline'
size?: 'sm' | 'lg'
className?: string
}>()
const sizeClass =
size === 'lg'
? 'rounded-full px-8 py-4 text-sm font-bold tracking-wider'
: 'rounded-2xl px-4 py-2 text-sm font-semibold'
</script>
<template>
@@ -19,7 +26,7 @@ const {
:href="href"
:class="
cn(
'rounded-2xl px-4 py-2 text-sm font-semibold',
sizeClass,
className,
variant === 'solid'
? 'bg-primary-comfy-yellow text-primary-comfy-ink transition-opacity hover:opacity-90'
@@ -27,6 +34,8 @@ const {
)
"
>
{{ label }}
<span class="ppformula-text-center">
<slot>{{ label }}</slot>
</span>
</a>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
interface CategoryItem {
label: string
value: string
}
const { categories, modelValue } = defineProps<{
categories: CategoryItem[]
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<nav
class="flex items-center gap-3 overflow-x-auto md:flex-col"
aria-label="Category filter"
>
<button
v-for="category in categories"
:key="category.value"
type="button"
:aria-pressed="modelValue === category.value"
:class="
cn(
'shrink-0 cursor-pointer self-start text-xs font-semibold tracking-wide whitespace-nowrap transition-colors',
modelValue === category.value
? 'text-primary-comfy-ink'
: 'text-primary-warm-gray hover:text-primary-comfy-canvas'
)
"
@click="emit('update:modelValue', category.value)"
>
<span v-if="modelValue === category.value" class="relative inline-block">
<span
class="bg-primary-comfy-yellow ppformula-text-center inline-flex items-center rounded-lg px-4 py-2"
>
{{ category.label }}
</span>
<!-- Triangle pointer -->
<span
class="border-t-primary-comfy-yellow absolute bottom-0 left-4 translate-y-full border-x-[6px] border-t-[6px] border-x-transparent"
aria-hidden="true"
/>
</span>
<span
v-else
class="bg-transparency-white-t4 ppformula-text-center inline-flex items-center rounded-lg px-4 py-2"
>
{{ category.label }}
</span>
</button>
</nav>
</template>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
import CategoryNav from './CategoryNav.vue'
import { deriveSections } from '../../config/contentSections'
const {
prefix,
locale = 'en',
readMoreHref
} = defineProps<{
prefix: string
locale?: Locale
readMoreHref?: string
}>()
const sections = deriveSections(prefix)
function key(sectionId: string, suffix: string): TranslationKey {
return `${prefix}.${sectionId}.${suffix}` as TranslationKey
}
const categories = computed(() =>
sections.map((s) => ({
label: t(key(s.id, 'label'), locale),
value: s.id
}))
)
const activeSection = ref(sections[0]?.id ?? '')
let observer: IntersectionObserver | null = null
let isScrolling = false
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (isScrolling) return
for (const entry of entries) {
if (entry.isIntersecting) {
activeSection.value = entry.target.id
}
}
},
{ rootMargin: '-20% 0px -60% 0px' }
)
for (const section of sections) {
const el = document.getElementById(section.id)
if (el) observer.observe(el)
}
})
onUnmounted(() => {
observer?.disconnect()
})
function scrollToSection(id: string) {
activeSection.value = id
isScrolling = true
const el = document.getElementById(id)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
setTimeout(() => {
isScrolling = false
}, 800)
}
</script>
<template>
<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 lg:block lg:w-48 lg:shrink-0">
<div class="sticky top-32">
<CategoryNav
:categories="categories"
:model-value="activeSection"
@update:model-value="scrollToSection"
/>
</div>
</aside>
<!-- Content -->
<div class="flex-1">
<div
v-for="section in sections"
:id="section.id"
:key="section.id"
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
>
<h2
v-if="section.hasTitle"
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
>
{{ t(key(section.id, 'title'), locale) }}
</h2>
<template v-for="(block, i) in section.blocks" :key="i">
<!-- Paragraph -->
<p
v-if="block.type === 'paragraph'"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
v-html="t(key(section.id, `block.${i}`), locale)"
/>
<!-- Heading (h3) -->
<h3
v-else-if="block.type === 'heading'"
class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic"
>
{{ t(key(section.id, `block.${i}.heading`), locale) }}
</h3>
<!-- Bullet list -->
<ul
v-else-if="block.type === 'list'"
class="mt-4 space-y-1 pl-5 text-sm"
>
<li
v-for="(item, j) in t(
key(section.id, `block.${i}`),
locale
).split('\n')"
:key="j"
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"
/>
{{ item }}
</li>
</ul>
<!-- Ordered list -->
<ol
v-else-if="block.type === 'ordered-list'"
class="mt-4 space-y-1 pl-1 text-sm"
>
<li
v-for="(item, j) in t(
key(section.id, `block.${i}.ol`),
locale
).split('\n')"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-3"
>
<span
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
>
{{ String(j + 1).padStart(2, '0') }}
</span>
{{ item }}
</li>
</ol>
<!-- Image with caption -->
<figure v-else-if="block.type === 'image'" class="my-8">
<img
:src="t(key(section.id, `block.${i}.src`), locale)"
:alt="t(key(section.id, `block.${i}.alt`), locale)"
class="w-full rounded-2xl object-cover"
/>
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
{{ t(key(section.id, `block.${i}.caption`), locale) }}
</figcaption>
</figure>
<!-- Blockquote -->
<blockquote
v-else-if="block.type === 'blockquote'"
:class="
cn(
'border-primary-comfy-yellow my-8 rounded-2xl border-l-4 p-8',
'bg-(--site-bg-soft)'
)
"
>
<p
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
>
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
</p>
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">
{{ t(key(section.id, `block.${i}.name`), locale) }}
</p>
</blockquote>
<!-- Author card -->
<div
v-else-if="block.type === 'author'"
:class="cn('mt-8 rounded-2xl p-6', 'bg-(--site-bg-soft)')"
>
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t(key(section.id, `block.${i}.label`), locale) }}
</span>
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
{{ t(key(section.id, `block.${i}.name`), locale) }}
</p>
<p class="text-primary-comfy-canvas text-xs">
{{ t(key(section.id, `block.${i}.role`), locale) }}
</p>
</div>
</template>
</div>
<!-- Read more CTA -->
<div v-if="readMoreHref" class="mt-8 flex justify-center">
<BrandButton :href="readMoreHref" variant="solid" size="lg">
<span class="ppformula-text-center flex items-center gap-2">
{{ t('customers.story.readMore' as TranslationKey, locale) }}
<span class="text-base"></span>
</span>
</BrandButton>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FAQSection from './FAQSection.vue'
const meta: Meta<typeof FAQSection> = {
title: 'Website/Common/FAQSection',
component: FAQSection,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
})
],
args: {
headingKey: 'download.faq.heading',
faqPrefix: 'download.faq',
faqCount: 3
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const ManyItems: Story = {
args: {
headingKey: 'download.faq.heading',
faqPrefix: 'download.faq',
faqCount: 8
}
}

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, reactive } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
locale = 'en',
headingKey,
faqPrefix,
faqCount
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
faqPrefix: string
faqCount: number
}>()
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
{ length: faqCount },
(_, i) => ({
q: `${faqPrefix}.${i + 1}.q` as TranslationKey,
a: `${faqPrefix}.${i + 1}.a` as TranslationKey
})
)
const faqs = computed(() =>
faqKeys.map(({ q, a }) => ({
question: t(q, locale),
answer: t(a, locale)
}))
)
const expanded = reactive(faqKeys.map(() => false))
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
{{ t(headingKey, locale) }}
</h2>
</div>
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
:key="index"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:id="`faq-trigger-${index}`"
type="button"
:aria-expanded="expanded[index]"
:aria-controls="`faq-panel-${index}`"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between text-left',
index === 0 ? 'pb-6' : 'py-6'
)
"
@click="toggle(index)"
>
<span
:class="
cn(
'text-lg font-light md:text-xl',
expanded[index]
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas'
)
"
>
{{ faq.question }}
</span>
<span
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
aria-hidden="true"
>
{{ expanded[index] ? '' : '+' }}
</span>
</button>
<section
v-show="expanded[index]"
:id="`faq-panel-${index}`"
role="region"
:aria-labelledby="`faq-trigger-${index}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

@@ -109,7 +109,7 @@ onUnmounted(() => {
role="dialog"
aria-modal="true"
: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 md:hidden"
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">
@@ -173,7 +173,9 @@ onUnmounted(() => {
v-if="item.badge"
class="bg-primary-comfy-yellow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-bold"
>
<span class="inline-block skew-x-12">{{ item.badge }}</span>
<span class="ppformula-text-center inline-block skew-x-12">{{
item.badge
}}</span>
</span>
<img
v-if="item.external"

View File

@@ -81,7 +81,9 @@ const emit = defineEmits<{
v-if="item.badge"
class="bg-primary-comfy-yellow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-[9px]/3 leading-none font-bold"
>
<span class="inline-block skew-x-12">{{ item.badge }}</span>
<span class="ppformula-text-center inline-block skew-x-12">{{
item.badge
}}</span>
</span>
<img
v-if="item.external"

View File

@@ -1,13 +1,20 @@
<script setup lang="ts">
const { segments, segmentClass = 'px-6' } = defineProps<{
import { cn } from '@comfyorg/tailwind-utils'
const {
segments,
segmentClass = 'px-6',
textClass = 'text-2xl sm:text-3xl lg:text-5xl'
} = defineProps<{
segments: Array<{ text?: string; logoSrc?: string; logoAlt?: string }>
segmentClass?: string
textClass?: string
}>()
</script>
<template>
<div
class="font-formula-condensed flex h-9 items-stretch font-semibold lg:h-auto"
class="font-formula-condensed flex h-11 items-stretch font-semibold **:select-none sm:h-auto"
>
<img
src="/icons/node-left.svg"
@@ -28,18 +35,27 @@ const { segments, segmentClass = 'px-6' } = defineProps<{
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center justify-center py-1 lg:py-5"
:class="segmentClass"
:class="
cn(
'bg-primary-comfy-yellow text-primary-comfy-ink flex items-center justify-center py-1.5 transition-all duration-300 sm:py-3 lg:py-5',
segmentClass
)
"
>
<img
v-if="segment.logoSrc"
:src="segment.logoSrc"
:alt="segment.logoAlt ?? ''"
class="inline-block h-4 brightness-0 lg:h-10"
class="inline-block h-5 brightness-0 transition-all duration-300 sm:h-7 lg:h-10"
/>
<span
v-else
class="inline-block translate-y-0.5 text-xl font-bold whitespace-nowrap lg:translate-y-1 lg:text-5xl"
:class="
cn(
'inline-block translate-y-1 font-bold whitespace-nowrap transition-all duration-300',
textClass
)
"
>
{{ segment.text }}
</span>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { title, description, cta, href, bg } = defineProps<{
title: string
description: string
@@ -11,8 +13,12 @@ const { title, description, cta, href, bg } = defineProps<{
<template>
<a
:href="href"
class="flex flex-col justify-between rounded-2xl p-8 transition-opacity hover:opacity-90"
:class="bg"
:class="
cn(
'flex flex-col justify-between rounded-2xl p-8 transition-opacity hover:opacity-90',
bg
)
"
>
<h3 class="text-3xl font-light whitespace-pre-line text-white lg:text-4xl">
{{ title }}

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import ProductCard from './ProductCard.vue'
type Product = 'local' | 'cloud' | 'api' | 'enterprise'
const {
locale = 'en',
excludeProduct,
labelKey = ''
} = defineProps<{
locale?: Locale
excludeProduct?: Product
labelKey?: TranslationKey
}>()
const routes = getRoutes(locale)
function cardDef(product: Product, href: string, bg: string) {
return {
product,
title: t(`products.${product}.title`, locale),
description: t(`products.${product}.description`, locale),
cta: t(`products.${product}.cta`, locale),
href,
bg
}
}
const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
cardDef('local', routes.download, 'bg-primary-warm-gray'),
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
]
const cards = excludeProduct
? allCards.filter((c) => c.product !== excludeProduct)
: allCards
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<!-- Header -->
<div class="flex flex-col items-center text-center">
<p
v-if="labelKey"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t(labelKey, locale) }}
</p>
<h2
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t('products.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas/70 mt-4 text-sm">
{{ t('products.subheading', locale) }}
</p>
</div>
<!-- Cards -->
<div
:class="
cn(
'mt-16 grid grid-cols-1 gap-4',
cards.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<ProductCard v-for="card in cards" :key="card.product" v-bind="card" />
</div>
</section>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
const {
logoSrc = '/icons/logo.svg',
logoAlt = 'Comfy',
text = 'LOCAL'
} = defineProps<{
logoSrc?: string
logoAlt?: string
text?: string
}>()
</script>
<template>
<div class="font-formula-condensed flex items-stretch font-semibold">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
>
<img
:src="logoSrc"
:alt="logoAlt"
class="inline-block h-6 brightness-0 lg:h-10"
/>
</span>
<img
src="/icons/node-union-2size.svg"
alt=""
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
>
<span
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
>
{{ text }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px my-auto h-7.25 self-center lg:h-15.5"
aria-hidden="true"
/>
</div>
</template>

View File

@@ -21,9 +21,8 @@ useFrameScrub(canvasRef, {
scrollTrigger: (canvas) => ({
trigger: canvas,
start: 'top bottom',
endTrigger: footerRef.value,
end: 'bottom bottom',
scrub: true
end: 'top bottom',
scrub: 1
})
})

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
@@ -86,7 +87,7 @@ const ctaButtons = [
},
{
label: t('nav.launchCloud', locale),
href: externalLinks.app,
href: externalLinks.cloud,
primary: true
}
]
@@ -126,7 +127,7 @@ let mq: MediaQueryList
onMounted(() => {
currentPath.value = window.location.pathname
mq = window.matchMedia('(min-width: 768px)')
mq = window.matchMedia('(min-width: 1024px)')
mq.addEventListener('change', onMediaChange)
document.addEventListener('keydown', onKeydown)
document.addEventListener('astro:after-swap', onNavigate)
@@ -149,22 +150,22 @@ onUnmounted(() => {
/>
<nav
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between px-6 py-5 md:px-20 md:py-8"
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between px-6 py-5 lg:px-10 lg:py-8 xl:px-20"
aria-label="Main navigation"
>
<a :href="routes.home" aria-label="Comfy home">
<img src="/icons/logomark.svg" alt="Comfy" class="h-8 md:hidden" />
<img src="/icons/logomark.svg" alt="Comfy" class="h-8 lg:hidden" />
<img
src="/icons/logo.svg"
alt="Comfy"
class="hidden h-10 w-36 object-contain object-left md:block"
class="hidden h-10 w-36 object-contain object-left lg:block"
/>
</a>
<!-- Desktop nav links -->
<div
data-testid="desktop-nav-links"
class="hidden items-center gap-10 md:flex"
class="hidden items-center gap-4 lg:flex xl:gap-10"
>
<NavDesktopLink
v-for="link in navLinks"
@@ -181,7 +182,7 @@ onUnmounted(() => {
<!-- Desktop CTA buttons -->
<div
data-testid="desktop-nav-cta"
class="hidden items-center gap-2 md:flex"
class="hidden items-center gap-2 lg:flex"
>
<BrandButton
v-for="cta in ctaButtons"
@@ -196,11 +197,13 @@ onUnmounted(() => {
<!-- Mobile hamburger -->
<button
ref="hamburgerRef"
class="flex size-10 items-center justify-center rounded-xl md:hidden"
: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"

View File

@@ -14,8 +14,11 @@ const logos = [
'Ubisoft'
]
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
</script>
<template>
@@ -23,7 +26,7 @@ const row2 = logos.slice(6)
<!-- Single row on desktop -->
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div
v-for="(logo, i) in [...logos, ...logos]"
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
@@ -38,7 +41,7 @@ const row2 = logos.slice(6)
>
<div class="animate-marquee flex items-center gap-8">
<div
v-for="(logo, i) in [...row1, ...row1]"
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
@@ -47,7 +50,7 @@ const row2 = logos.slice(6)
</div>
<div class="animate-marquee-reverse flex items-center gap-8">
<div
v-for="(logo, i) in [...row2, ...row2]"
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<div
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
>
<div class="size-full" />
<!-- Bottom control bar -->
<div
class="absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 lg:px-6 lg:py-5"
>
<!-- Play button -->
<button
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-full lg:size-10"
:aria-label="locale === 'zh-CN' ? '播放' : 'Play'"
>
<svg class="ml-0.5 size-3 lg:size-4" viewBox="0 0 24 24" fill="#211927">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<!-- Progress bar -->
<div class="flex flex-1 items-center">
<div class="h-1 w-full rounded-full bg-white/20">
<div class="bg-primary-comfy-yellow h-full w-1/6 rounded-full" />
</div>
</div>
<!-- Timestamp -->
<span class="shrink-0 text-xs text-white/80 lg:text-sm">00:13</span>
<!-- Fullscreen button -->
<button
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-lg lg:size-10"
:aria-label="locale === 'zh-CN' ? '全屏' : 'Fullscreen'"
>
<svg
class="size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="none"
stroke="#211927"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
</button>
<!-- Mute button -->
<button
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-lg lg:size-10"
:aria-label="locale === 'zh-CN' ? '静音' : 'Mute'"
>
<svg
class="size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="#211927"
stroke="#211927"
stroke-width="1.5"
>
<path
d="M11 5L6 9H2v6h4l5 4V5z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line x1="23" y1="9" x2="17" y2="15" stroke-width="2.5" />
<line x1="17" y1="9" x2="23" y2="15" stroke-width="2.5" />
</svg>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
function tk(suffix: string): TranslationKey {
return `contact.form.${suffix}` as TranslationKey
}
const firstName = ref('')
const lastName = ref('')
const company = ref('')
const phone = ref('')
const selectedPackage = ref('')
const comfyUsage = ref('')
const lookingFor = ref('')
const packageOptions = [
'packageIndividual',
'packageTeams',
'packageEnterprise'
] as const
const usageOptions = [
'usingYesProduction',
'usingYesTesting',
'usingNotYet',
'usingOtherTools'
] as const
const inputClass =
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
const sectionRef = ref<HTMLElement>()
const badgeRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
const descRef = ref<HTMLElement>()
const imageRef = ref<HTMLElement>()
const formRef = ref<HTMLElement>()
useHeroAnimation({
section: sectionRef,
textEls: [badgeRef, headingRef, descRef],
logo: imageRef,
video: formRef,
parallax: false
})
function handleSubmit() {
// TODO: implement form submission
}
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<span
ref="badgeRef"
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
>
{{ t(tk('badge'), locale) }}
</span>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
<div ref="imageRef" class="mt-8 -ml-20 overflow-hidden rounded-2xl">
<img
src="/images/contact/c-projection.webp"
alt=""
class="w-full rounded-2xl object-cover"
/>
</div>
</div>
<!-- Right column: form -->
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
<form class="space-y-6" @submit.prevent="handleSubmit">
<!-- First Name + Last Name -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('firstName'), locale) }}*
</label>
<input
v-model="firstName"
type="text"
required
:placeholder="t(tk('firstNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lastName'), locale) }}*
</label>
<input
v-model="lastName"
type="text"
required
:placeholder="t(tk('lastNamePlaceholder'), locale)"
:class="inputClass"
/>
</div>
</div>
<!-- Company + Phone -->
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('company'), locale) }}*
</label>
<input
v-model="company"
type="text"
required
:placeholder="t(tk('companyPlaceholder'), locale)"
:class="inputClass"
/>
</div>
<div class="mt-6 lg:mt-0">
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('phone'), locale) }}
</label>
<input v-model="phone" type="tel" :class="inputClass" />
</div>
</div>
<!-- Package selection -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('packageQuestion'), locale) }}
</p>
<div class="mt-3 flex gap-3">
<label
v-for="opt in packageOptions"
:key="opt"
:class="
cn(
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
selectedPackage === opt
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
)
"
>
<input
v-model="selectedPackage"
type="radio"
name="package"
:value="opt"
class="sr-only"
/>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
selectedPackage === opt
? 'border-primary-comfy-yellow'
: 'border-primary-warm-gray/40'
)
"
>
<span
v-if="selectedPackage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
{{ t(tk(opt), locale) }}
</label>
</div>
</div>
<!-- Comfy usage -->
<div>
<p class="text-primary-comfy-canvas text-xs">
{{ t(tk('usingComfy'), locale) }}
</p>
<div class="mt-3 space-y-3">
<label
v-for="opt in usageOptions"
:key="opt"
class="flex cursor-pointer items-center gap-3"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-full border',
comfyUsage === opt
? 'border-primary-comfy-yellow'
: 'border-(--site-border-subtle)'
)
"
>
<span
v-if="comfyUsage === opt"
class="bg-primary-comfy-yellow size-2 rounded-full"
/>
</span>
<input
v-model="comfyUsage"
type="radio"
:value="opt"
class="sr-only"
/>
<span class="text-primary-comfy-canvas text-sm">
{{ t(tk(opt), locale) }}
</span>
</label>
</div>
</div>
<!-- Looking for -->
<div>
<label class="text-primary-comfy-canvas text-xs">
{{ t(tk('lookingFor'), locale) }}
</label>
<textarea
v-model="lookingFor"
:placeholder="t(tk('lookingForPlaceholder'), locale)"
:class="cn(inputClass, 'min-h-24 resize-y')"
/>
</div>
<!-- Submit -->
<div>
<button
type="submit"
class="ppformula-text-center border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink cursor-pointer rounded-lg border px-8 py-3 text-sm font-bold tracking-wider transition-colors"
>
{{ t(tk('submit'), locale) }}
</button>
</div>
</form>
</div>
</section>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
interface ButtonProps {
label: string
href: string
}
const { label, title, description, primaryButton, secondaryButton, image } =
defineProps<{
label?: string
title: string
description?: string
primaryButton?: ButtonProps
secondaryButton?: ButtonProps
image?: string
}>()
</script>
<template>
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
<span
v-if="label"
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ label }}
</span>
<h1
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
>
{{ title }}
</h1>
<p
v-if="description"
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
>
{{ description }}
</p>
<div
v-if="primaryButton || secondaryButton"
class="mt-8 flex flex-wrap justify-center gap-3"
>
<BrandButton
v-if="primaryButton"
:href="primaryButton.href"
:label="primaryButton.label"
variant="outline"
/>
<BrandButton
v-if="secondaryButton"
:href="secondaryButton.href"
:label="secondaryButton.label"
variant="solid"
/>
</div>
</div>
<div v-if="image" class="mt-12 overflow-hidden px-6 lg:mt-16 lg:px-20">
<img :src="image" :alt="title" class="w-full rounded-3xl object-cover" />
</div>
</section>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const feedbacks = [
{
quote: 'customers.feedback.quote1' as const,
name: 'customers.feedback.name1' as const,
role: 'customers.feedback.role1' as const
},
{
quote: 'customers.feedback.quote2' as const,
name: 'customers.feedback.name2' as const,
role: 'customers.feedback.role2' as const
},
{
quote: 'customers.feedback.quote3' as const,
name: 'customers.feedback.name3' as const,
role: 'customers.feedback.role3' as const
}
]
const trackRef = ref<HTMLElement>()
const progress = ref(0)
function updateProgress() {
const el = trackRef.value
if (!el) return
const max = el.scrollWidth - el.clientWidth
progress.value = max > 0 ? el.scrollLeft / max : 0
}
function scroll(direction: -1 | 1) {
const el = trackRef.value
if (!el) return
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
}
const progressPercent = computed(() => `${progress.value * 100}%`)
onMounted(() => {
trackRef.value?.addEventListener('scroll', updateProgress, { passive: true })
})
onUnmounted(() => {
trackRef.value?.removeEventListener('scroll', updateProgress)
})
</script>
<template>
<section class="px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<div
ref="trackRef"
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
>
<div
v-for="(fb, i) in feedbacks"
:key="i"
class="bg-transparency-white-t4 flex w-full shrink-0 snap-start flex-col justify-between rounded-3xl p-8 lg:w-3/4 lg:p-12"
>
<p class="text-primary-comfy-canvas text-2xl/relaxed font-light">
"{{ t(fb.quote, locale) }}"
</p>
<div class="mt-12">
<p class="text-primary-comfy-yellow text-base font-medium">
{{ t(fb.name, locale) }},
</p>
<p class="text-primary-comfy-yellow text-base font-medium">
{{ t(fb.role, locale) }}
</p>
</div>
</div>
</div>
<!-- Controls -->
<div class="mt-10 flex items-center gap-4">
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
:style="{ width: progressPercent }"
/>
</div>
<!-- Prev -->
<button
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
:aria-label="locale === 'zh-CN' ? '上一条' : 'Previous'"
@click="scroll(-1)"
>
<img
src="/icons/arrow-right.svg"
alt=""
class="size-3 rotate-180 opacity-60 invert"
/>
</button>
<!-- Next -->
<button
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
:aria-label="locale === 'zh-CN' ? '下一条' : 'Next'"
@click="scroll(1)"
>
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useHeroAnimation } from '../../composables/useHeroAnimation'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const sectionRef = ref<HTMLElement>()
const logoRef = ref<HTMLElement>()
const labelRef = ref<HTMLElement>()
const headingRef = ref<HTMLElement>()
const bodyRef = ref<HTMLElement>()
const videoRef = ref<HTMLElement>()
useHeroAnimation({
section: sectionRef,
textEls: [labelRef, headingRef, bodyRef],
logo: logoRef,
video: videoRef
})
</script>
<template>
<section ref="sectionRef" class="pt-12 lg:pt-20">
<div
class="flex flex-col items-center text-center lg:flex-row lg:items-start lg:text-left"
>
<!-- 3D logo graphic -->
<div
ref="logoRef"
class="order-2 mt-8 w-full lg:order-1 lg:mt-0 lg:w-5/12"
>
<img
src="/images/customers/c-projection.webp"
alt="Comfy 3D logo"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>
<!-- Text content -->
<div
class="order-1 flex flex-col items-center lg:order-2 lg:w-7/12 lg:items-start lg:pt-24 lg:pl-12"
>
<span
ref="labelRef"
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('customers.hero.label', locale) }}
</span>
<h1
ref="headingRef"
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="text-primary-warm-gray mt-6 max-w-md text-sm/relaxed lg:text-base"
>
{{ t('customers.hero.body', locale) }}
</p>
</div>
</div>
<!-- Video overlapping the hero graphic -->
<div ref="videoRef" class="-mt-16 px-20 pb-40 lg:-mt-72">
<VideoPlayer :locale />
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { customerStories } from '../../config/customerStories'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
</script>
<template>
<section
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in customerStories"
:key="story.slug"
:href="`${prefix}/customers/${story.slug}`"
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
>
<!-- Image -->
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
<div
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
:style="{ backgroundImage: `url(${story.image})` }"
/>
</div>
<!-- Content -->
<div class="flex flex-1 flex-col justify-between px-6 pt-4 pb-6">
<div>
<span
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
>
{{ t(story.category, locale) }}
</span>
<h3
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light lg:text-xl/snug"
>
{{ t(story.title, locale) }}
</h3>
</div>
<div
class="mt-8 flex items-center gap-3 text-xs font-semibold tracking-widest uppercase"
>
<span
class="bg-primary-comfy-yellow flex size-8 items-center justify-center rounded-full"
>
<img src="/icons/arrow-right.svg" alt="" class="ml-0.5 size-3" />
</span>
<span class="text-primary-comfy-canvas">
{{ t('customers.story.viewArticle', locale) }}
</span>
</div>
</div>
</a>
</section>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-16 lg:px-20 lg:py-40">
<VideoPlayer :locale />
</section>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
title,
image,
href,
locale = 'en'
} = defineProps<{
title: string
image: string
href: string
locale?: Locale
}>()
</script>
<template>
<section class="px-4 py-16 lg:px-20 lg:py-24">
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
{{ t('customers.story.whatsNext' as TranslationKey, locale) }}
</h2>
<div
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
>
<a :href="href" class="shrink-0 lg:w-1/2">
<img
:src="image"
:alt="title"
class="w-full rounded-4xl object-cover"
/>
</a>
<div class="flex flex-col gap-6">
<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 text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
>
<span class="text-lg font-bold"></span>
</span>
<span
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
>
{{ t('customers.story.viewArticle' as TranslationKey, locale) }}
</span>
</a>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t('gallery.contact.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line"
>
{{ t('gallery.contact.heading', locale) }}
</h2>
<a
href="mailto:contact@comfy.org"
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink mt-8 inline-flex items-center justify-center rounded-2xl border-2 px-8 py-3 text-sm font-bold tracking-wider uppercase transition-colors"
>
{{ t('gallery.contact.cta', locale) }}
</a>
</section>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { GalleryItem } from './GallerySection.vue'
const {
item,
locale = 'en',
hero = false,
mobile = false
} = defineProps<{
item: GalleryItem
locale?: Locale
hero?: boolean
mobile?: boolean
}>()
defineEmits<{ click: [] }>()
</script>
<template>
<div class="group block cursor-pointer" @click="$emit('click')">
<div
class="relative overflow-hidden rounded-2xl"
:class="hero ? 'aspect-21/9' : mobile ? 'aspect-4/3' : 'aspect-3/2'"
>
<img
:src="item.image"
:alt="item.title"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<!-- Desktop hover overlay -->
<div
v-if="!mobile"
class="absolute inset-0 flex items-end bg-linear-to-t from-black/60 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
>
<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-primary-comfy-canvas text-xs">
{{ t('gallery.card.by', locale) }}
<span class="text-primary-comfy-yellow">{{
item.userAlias
}}</span>
{{ t('gallery.card.and', locale) }}
<span class="text-primary-comfy-yellow">{{
item.teamAlias
}}</span>
{{ t('gallery.card.teamUsing', locale) }}
<span class="text-primary-comfy-yellow">{{ item.tool }}</span>
</p>
</div>
<span
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-full"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
class="text-primary-comfy-ink"
>
<path
d="M1 7h12m0 0L8 2m5 5L8 12"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
</div>
</div>
<!-- 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-primary-comfy-canvas text-xs">
{{ t('gallery.card.by', locale) }}
<span class="text-primary-comfy-yellow">{{ item.userAlias }}</span>
{{ t('gallery.card.and', locale) }}
<span class="text-primary-comfy-yellow">{{ item.teamAlias }}</span>
{{ t('gallery.card.teamUsing', locale) }}
<span class="text-primary-comfy-yellow">{{ item.tool }}</span>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { GalleryItem } from './GallerySection.vue'
const {
items,
initialIndex = 0,
locale = 'en'
} = defineProps<{
items: GalleryItem[]
initialIndex?: number
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const activeIndex = ref(initialIndex)
const transitioning = ref(false)
const thumbnailRefs = ref<HTMLButtonElement[]>([])
const activeItem = computed(() => items[activeIndex.value])
function scrollToActiveThumbnail() {
void nextTick(() => {
thumbnailRefs.value[activeIndex.value]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
})
})
}
watch(activeIndex, scrollToActiveThumbnail)
function selectThumbnail(index: number) {
if (index === activeIndex.value || transitioning.value) return
transitioning.value = true
setTimeout(() => {
activeIndex.value = index
setTimeout(() => {
transitioning.value = false
}, 50)
}, 200)
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
emit('close')
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
if (e.key === 'ArrowLeft' && activeIndex.value > 0)
selectThumbnail(activeIndex.value - 1)
if (e.key === 'ArrowRight' && activeIndex.value < items.length - 1)
selectThumbnail(activeIndex.value + 1)
}
watch(
() => initialIndex,
(val) => {
activeIndex.value = val
}
)
const dialogRef = ref<HTMLDialogElement>()
onMounted(() => {
document.body.style.overflow = 'hidden'
dialogRef.value?.showModal()
scrollToActiveThumbnail()
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="activeItem.title"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-between border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@close="emit('close')"
>
<!-- Close button -->
<button
aria-label="Close"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<!-- Desktop layout -->
<div class="relative hidden w-full items-start pt-12 lg:flex">
<!-- Left: info card -->
<div
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
class="gap-4 transition-opacity duration-200"
>
<h2 class="text-2xl font-bold">{{ activeItem.title }}</h2>
<p class="mt-2 text-xs">
{{ t('gallery.card.by', locale) }}
<span class="font-bold">{{ activeItem.userAlias }}</span>
{{ t('gallery.card.and', locale) }}
<span class="font-bold">{{ activeItem.teamAlias }}</span>
{{ t('gallery.card.teamUsing', locale) }}
<span class="font-bold">{{ activeItem.tool }}</span>
</p>
</div>
<a
:href="activeItem.href"
class="border-primary-comfy-ink hover:bg-primary-comfy-ink hover:text-primary-comfy-yellow mt-24 inline-flex items-center justify-center rounded-full border-2 px-6 py-3 text-sm font-bold tracking-wider uppercase transition-colors"
>
{{ t('gallery.detail.visitHub', locale) }}
</a>
</div>
<!-- Node link connector (horizontal) -->
<img
src="/icons/node-link.svg"
alt=""
class="relative top-15 z-20 -mx-px h-6"
/>
<!-- Right: large image -->
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex-1 overflow-hidden border-2 p-4"
>
<img
:src="activeItem.image"
:alt="activeItem.title"
:class="transitioning ? 'opacity-0' : 'opacity-100'"
class="size-full rounded-4xl object-cover transition-opacity duration-200"
/>
</div>
</div>
<!-- Mobile layout -->
<div
class="flex w-full flex-1 flex-col items-center justify-between pt-12 lg:hidden"
>
<!-- Image -->
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
>
<img
:src="activeItem.image"
:alt="activeItem.title"
:class="transitioning ? 'opacity-0' : 'opacity-100'"
class="w-full rounded-3xl object-cover transition-opacity duration-200"
/>
</div>
<!-- Node link connector (vertical) -->
<img
src="/icons/node-link.svg"
alt=""
class="relative z-20 -my-1 w-2 rotate-90"
/>
<!-- Info card -->
<div
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
class="transition-opacity duration-200"
>
<h2 class="text-xl font-bold">{{ activeItem.title }}</h2>
<p class="mt-2 text-xs">
{{ t('gallery.card.by', locale) }}
<span class="font-bold">{{ activeItem.userAlias }}</span>
{{ t('gallery.card.and', locale) }}
<span class="font-bold">{{ activeItem.teamAlias }}</span>
{{ t('gallery.card.teamUsing', locale) }}
<span class="font-bold">{{ activeItem.tool }}</span>
</p>
</div>
<a
:href="activeItem.href"
class="border-primary-comfy-ink hover:bg-primary-comfy-ink hover:text-primary-comfy-yellow mt-6 inline-flex w-full items-center justify-center rounded-full border-2 px-6 py-3 text-sm font-bold tracking-wider uppercase transition-colors"
>
{{ t('gallery.detail.visitHub', locale) }}
</a>
</div>
</div>
<!-- Thumbnail strip -->
<div class="mx-auto mt-6 max-w-full overflow-x-auto px-6">
<div class="flex items-end gap-3">
<button
v-for="(item, i) in items"
:ref="
(el: any) => {
if (el) thumbnailRefs[i] = el
}
"
:key="i"
class="shrink-0 cursor-pointer overflow-hidden rounded-xl border-0 bg-transparent p-0 transition-all duration-200"
:class="
i === activeIndex
? 'ring-primary-comfy-yellow size-16 ring-2 lg:size-30'
: 'size-12 opacity-70 hover:opacity-100 lg:size-22.5'
"
@click="selectThumbnail(i)"
>
<img
:src="item.image"
:alt="item.title"
class="size-full object-cover"
/>
</button>
</div>
</div>
</dialog>
</Teleport>
</template>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
export interface GalleryItem {
image: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
}
const items: GalleryItem[] = Array.from({ length: 12 }, () => ({
image: '/images/gallery/gallery.webp',
title: 'Image Title',
userAlias: 'User Alias',
teamAlias: 'Team Alias',
tool: 'Tool',
href: '#'
}))
/**
* Desktop layout pattern (repeating):
* Row A: full-width (1 item)
* Row B: 2-col equal (2 items)
* Row C: 3-col equal (3 items)
* Row D: large-left + 2 stacked small-right (3 items)
* Row E: 2 stacked small-left + large-right (3 items)
* = 1 + 2 + 3 + 3 + 3 = 12 items per cycle
*/
type RowLayout = 'full' | 'col-2' | 'col-3' | 'large-left' | 'large-right'
const LAYOUT_PATTERN: RowLayout[] = [
'full',
'col-2',
'col-3',
'large-left',
'large-right'
]
const ITEMS_PER_LAYOUT: Record<RowLayout, number> = {
full: 1,
'col-2': 2,
'col-3': 3,
'large-left': 3,
'large-right': 3
}
interface Row {
layout: RowLayout
items: GalleryItem[]
startIndex: number
}
const rows: Row[] = []
let idx = 0
let patternIdx = 0
while (idx < items.length) {
const layout = LAYOUT_PATTERN[patternIdx % LAYOUT_PATTERN.length]
const count = ITEMS_PER_LAYOUT[layout]
const slice = items.slice(idx, idx + count)
if (slice.length === 0) break
rows.push({ layout, items: slice, startIndex: idx })
idx += slice.length
patternIdx++
}
</script>
<template>
<section class="px-4 pb-20 lg:px-20">
<!-- Desktop grid -->
<div class="hidden flex-col gap-2 lg:flex">
<template v-for="(row, rowIdx) in rows" :key="rowIdx">
<!-- Symmetric rows: full / 2-col / 3-col -->
<div
v-if="
row.layout === 'full' ||
row.layout === 'col-2' ||
row.layout === 'col-3'
"
class="grid grid-cols-6 gap-2"
>
<GalleryCard
v-for="(item, i) in row.items"
:key="i"
:item="item"
:locale="locale"
:hero="row.layout === 'full'"
:class="
cn(
row.layout === 'full' && 'col-span-6',
row.layout === 'col-2' && 'col-span-3',
row.layout === 'col-3' && 'col-span-2'
)
"
@click="openDetail(row.startIndex + i)"
/>
</div>
<!-- Large left + 2 stacked right -->
<div
v-else-if="row.layout === 'large-left'"
class="grid grid-cols-3 gap-2"
>
<GalleryCard
:item="row.items[0]"
:locale="locale"
class="col-span-2 row-span-2"
@click="openDetail(row.startIndex)"
/>
<div class="col-span-1 flex flex-col gap-2">
<GalleryCard
:item="row.items[1]"
:locale="locale"
class="flex-1"
@click="openDetail(row.startIndex + 1)"
/>
<GalleryCard
:item="row.items[2]"
:locale="locale"
class="flex-1"
@click="openDetail(row.startIndex + 2)"
/>
</div>
</div>
<!-- 2 stacked left + large right -->
<div v-else class="grid grid-cols-3 gap-2">
<div class="col-span-1 flex flex-col gap-2">
<GalleryCard
:item="row.items[0]"
:locale="locale"
class="flex-1"
@click="openDetail(row.startIndex)"
/>
<GalleryCard
:item="row.items[1]"
:locale="locale"
class="flex-1"
@click="openDetail(row.startIndex + 1)"
/>
</div>
<GalleryCard
:item="row.items[2]"
:locale="locale"
class="col-span-2 row-span-2"
@click="openDetail(row.startIndex + 2)"
/>
</div>
</template>
</div>
<!-- Mobile list -->
<div class="flex flex-col gap-6 lg:hidden">
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item="item"
:locale="locale"
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items="items"
:initial-index="modalIndex"
:locale="locale"
@close="modalOpen = false"
/>
</section>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t('gallery.label', locale) }}
</span>
<h1
class="text-primary-warm-white mt-4 max-w-3xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t('gallery.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span>
</h1>
<p class="text-primary-warm-gray mt-4 max-w-lg text-sm lg:text-base">
{{ t('gallery.heroSubtitle', locale) }}
</p>
</section>
</template>

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