Compare commits
4 Commits
feat/node-
...
fix/cmd-a-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d775af7e0 | ||
|
|
7d2aaebe57 | ||
|
|
05bfe05cfc | ||
|
|
efae49093b |
@@ -32,12 +32,12 @@
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: add-model-page
|
||||
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
|
||||
---
|
||||
|
||||
# add-model-page
|
||||
|
||||
add, update, or remove model pages in the ComfyUI website.
|
||||
|
||||
## Trigger phrases
|
||||
|
||||
- `Add a model page for <model-name>`
|
||||
- `Update the model page for <model-name>`
|
||||
- `Remove <model-name> from model pages`
|
||||
|
||||
## Phase 1 — Parse the request
|
||||
|
||||
Extract:
|
||||
|
||||
- **action**: `add` | `update` | `remove`
|
||||
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
|
||||
|
||||
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
|
||||
Example: `flux1_dev.safetensors` → `flux1-dev`
|
||||
|
||||
## Architecture overview
|
||||
|
||||
Models come from two sources merged at build time:
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
|
||||
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
|
||||
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
|
||||
|
||||
To regenerate the JSON from workflow_templates:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
```
|
||||
|
||||
This writes `apps/website/src/config/generated-models.json` directly.
|
||||
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Gather model data (ADD / UPDATE)
|
||||
|
||||
Run the generator to get fresh data, then find the model:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
The JSON fields are:
|
||||
|
||||
- `slug` — URL slug
|
||||
- `name` — exact filename or display name for partner nodes
|
||||
- `huggingFaceUrl` — download URL (empty for partner nodes)
|
||||
- `directory` — `diffusion_models` | `loras` | … | `partner_nodes`
|
||||
- `workflowCount` — integer
|
||||
- `displayName` — human-readable name
|
||||
|
||||
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
|
||||
`generate-models.ts` and re-run. Otherwise tell the user.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Check for existing entry
|
||||
|
||||
```bash
|
||||
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
- Match found + action is `add` → switch to UPDATE flow automatically
|
||||
- No match + action is `update` → stop and tell the user
|
||||
|
||||
---
|
||||
|
||||
## Phase 4A — ADD: new partner/API model not in workflow_templates
|
||||
|
||||
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
|
||||
`apps/website/scripts/generate-models.ts`:
|
||||
|
||||
```typescript
|
||||
mymodel: { name: 'My Model', slug: 'my-model' },
|
||||
```
|
||||
|
||||
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
|
||||
in `generated-models.json` automatically.
|
||||
|
||||
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
|
||||
|
||||
```typescript
|
||||
'my-model': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/...',
|
||||
blogUrl: 'https://blog.comfy.org/...',
|
||||
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
|
||||
featured: true
|
||||
}
|
||||
```
|
||||
|
||||
No changes to `models.ts` or `translations.ts` are needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4B — UPDATE: edit existing entry
|
||||
|
||||
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
|
||||
featured). For `displayName` or `directory` changes, edit the entry directly in
|
||||
`generated-models.json` (until the next generator run would overwrite it — then
|
||||
fix the source in `generate-models.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4C — REMOVE: delete entry
|
||||
|
||||
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
|
||||
pointing to the replacement). No translation file changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verify TypeScript
|
||||
|
||||
```bash
|
||||
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
|
||||
```
|
||||
|
||||
Fix any type errors before proceeding. Common issues:
|
||||
|
||||
- `ModelDirectory` type not matching a new `directory` value — add it to the union
|
||||
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Create PR
|
||||
|
||||
```bash
|
||||
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
|
||||
git checkout -b $BRANCH
|
||||
git add apps/website/src/config/generated-models.json \
|
||||
apps/website/scripts/generate-models.ts \
|
||||
apps/website/src/config/model-metadata.ts
|
||||
git commit -m "feat(models): add model page for MODEL-SLUG"
|
||||
git push -u origin $BRANCH
|
||||
gh pr create \
|
||||
--title "Add model page: MODEL-SLUG" \
|
||||
--body "$(cat <<'EOF'
|
||||
Adds a new model page entry for MODEL-SLUG.
|
||||
|
||||
## Changes
|
||||
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
|
||||
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
For UPDATE use branch `update-model-page-MODEL-SLUG`.
|
||||
For REMOVE use `remove-model-page-MODEL-SLUG`.
|
||||
|
||||
---
|
||||
|
||||
## Error states
|
||||
|
||||
| Situation | Response |
|
||||
| ------------------------------- | ---------------------------------------------------------------- |
|
||||
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
|
||||
| Slug already exists (add) | Switch to update flow automatically |
|
||||
| Slug not found (update/remove) | Stop and ask user to confirm |
|
||||
| Typecheck fails | Fix the error before pushing |
|
||||
@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit "${TEST_FILES[@]}"
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit <test-file>
|
||||
pnpm test:unit -- <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
|
||||
@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit --update
|
||||
pnpm test:unit -- --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Cloud Nodes Pull
|
||||
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
|
||||
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
12
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,12 +106,17 @@ jobs:
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
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 \
|
||||
--ignore-errors source,unmapped
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
@@ -125,8 +130,7 @@ jobs:
|
||||
needs: merge
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
6
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -45,8 +45,12 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
11
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -58,7 +58,6 @@ jobs:
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -152,20 +151,10 @@ jobs:
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
|
||||
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
24
.github/workflows/detect-unreviewed-merge.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Detect Unreviewed Merge
|
||||
|
||||
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
|
||||
# tracking issues are filed in Comfy-Org/unreviewed-merges.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: detect-unreviewed-merge-${{ github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
|
||||
with:
|
||||
approval-mode: latest-per-reviewer
|
||||
secrets:
|
||||
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}
|
||||
123
.github/workflows/model-page-discovery.yaml
vendored
@@ -1,123 +0,0 @@
|
||||
name: Model Page Discovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Fetch model labels from hub API
|
||||
id: hub
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
|
||||
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: apps/website/src/config/generated-models.json
|
||||
|
||||
- name: Compare against existing models
|
||||
id: compare
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
|
||||
|
||||
EXISTING_SLUGS=$(node -e "
|
||||
const fs = require('fs');
|
||||
const models = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'apps/website/src/config/generated-models.json',
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
console.log(JSON.stringify(models.map(m => m.slug)));
|
||||
" 2>/dev/null || echo '[]')
|
||||
|
||||
ADDED_SLUGS=$(node -e "
|
||||
const hub = $HUB_SLUGS;
|
||||
const existing = new Set($EXISTING_SLUGS);
|
||||
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
|
||||
")
|
||||
|
||||
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
|
||||
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
|
||||
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
|
||||
|
||||
if [ "\$COUNT" -eq 0 ]; then
|
||||
echo "No new models found."
|
||||
else
|
||||
echo "Found \$COUNT new model(s)"
|
||||
fi
|
||||
|
||||
- name: Check for existing open discovery issue
|
||||
id: existing_issue
|
||||
if: steps.compare.outputs.new_count != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
COUNT=$(gh issue list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--state open \
|
||||
--search 'in:title "New models detected"' \
|
||||
--json number \
|
||||
--jq 'length')
|
||||
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Open GitHub issue for new models
|
||||
if: |
|
||||
steps.compare.outputs.new_count != '0' &&
|
||||
steps.existing_issue.outputs.open_count == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
|
||||
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
|
||||
shell: bash
|
||||
run: |
|
||||
SLUG_LIST=$(node -e "
|
||||
const slugs = $NEW_SLUGS;
|
||||
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
|
||||
")
|
||||
|
||||
gh issue create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "New models detected — add to model pages" \
|
||||
--body "## $NEW_COUNT new model(s) found in hub
|
||||
|
||||
The weekly model discovery scan found model labels on the hub not yet in
|
||||
\`apps/website/src/config/generated-models.json\`.
|
||||
|
||||
### New slugs ($NEW_COUNT)
|
||||
|
||||
$SLUG_LIST
|
||||
|
||||
### Next steps
|
||||
|
||||
1. Review which of these warrant an SEO model page
|
||||
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
|
||||
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
|
||||
|
||||
---
|
||||
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
|
||||
|
||||
- name: Skip — open issue already exists
|
||||
if: |
|
||||
steps.compare.outputs.new_count != '0' &&
|
||||
steps.existing_issue.outputs.open_count != '0'
|
||||
run: echo "An open discovery issue already exists — skipping creation."
|
||||
|
||||
- name: No new models found
|
||||
if: steps.compare.outputs.new_count == '0'
|
||||
run: echo "No new models found — nothing to do."
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
|
||||
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
pnpm build
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles and
|
||||
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
|
||||
# Vercel website production deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshots:
|
||||
refresh-snapshot:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,39 +31,28 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
uses: ./.github/actions/cloud-nodes-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
body: |
|
||||
Automated refresh of remote-data snapshots used by the website
|
||||
build:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
|
||||
board API
|
||||
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
|
||||
`/api/object_info`
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
|
||||
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
|
||||
without the respective API keys continue to use the committed
|
||||
snapshot (with a warning annotation in CI).
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
6
.github/workflows/weekly-docs-check.yaml
vendored
@@ -40,11 +40,11 @@ jobs:
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
# Check if packages are already available locally
|
||||
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
|
||||
echo "Installing TypeScript and Vue compiler globally..."
|
||||
pnpm add -g typescript @vue/compiler-sfc
|
||||
pnpm install -g typescript @vue/compiler-sfc
|
||||
else
|
||||
echo "TypeScript and Vue compiler already available globally"
|
||||
echo "TypeScript and Vue compiler already available locally"
|
||||
fi
|
||||
|
||||
- name: Run Claude Documentation Review
|
||||
|
||||
5
.gitignore
vendored
@@ -19,7 +19,6 @@ yarn.lock
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.nx
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -90,6 +89,10 @@ storybook-static
|
||||
# MCP Servers
|
||||
.playwright-mcp/*
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
|
||||
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
".i18nrc.cjs",
|
||||
".nx/*",
|
||||
"**/vite.config.*.timestamp*",
|
||||
"**/vitest.config.*.timestamp*",
|
||||
"components.d.ts",
|
||||
@@ -84,17 +85,6 @@
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"vitest/hoisted-apis-on-top": "error",
|
||||
"typescript/no-misused-spread": "error",
|
||||
"vitest/consistent-each-for": [
|
||||
"error",
|
||||
{
|
||||
"test": "for",
|
||||
"it": "for",
|
||||
"describe": "for",
|
||||
"suite": "for"
|
||||
}
|
||||
],
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
|
||||
17
AGENTS.md
@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -237,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
@@ -307,20 +308,6 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
111
CODEOWNERS
@@ -1,60 +1,95 @@
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/templates/ @christian-byrne @comfyui-wiki @Comfy-Org/comfy_frontend_devs
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/imagecrop/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useImageCrop.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/painter/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @Comfy-Org/comfy_frontend_devs
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/services/load3dService.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @Comfy-Org/comfy_frontend_devs
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987 @Comfy-Org/comfy_frontend_devs
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
B -->|Dev server stuck| C[pnpm dev hangs]
|
||||
B -->|Dev server stuck| C[nx serve hangs]
|
||||
B -->|Build errors| D[Check build issues]
|
||||
B -->|Lint errors| Q[Check linting issues]
|
||||
B -->|Dependency issues| E[Package problems]
|
||||
@@ -23,7 +23,7 @@ flowchart TD
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
@@ -41,11 +41,11 @@ flowchart TD
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` gets stuck and won't start
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs during Vite startup
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
@@ -65,7 +65,7 @@ flowchart TD
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm clean:all && pnpm i
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
@@ -73,7 +73,7 @@ flowchart TD
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- stale local build cache
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
|
||||
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
|
||||
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
|
||||
"lint": "eslint src --cache",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
@@ -36,5 +33,88 @@
|
||||
"vite-plugin-html": "catalog:",
|
||||
"vite-plugin-vue-devtools": "catalog:",
|
||||
"vue-tsc": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:desktop",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite preview --config vite.config.mts"
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook dev -p 6007"
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "storybook build -o dist/storybook"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/storybook"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "eslint src --cache"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vue-tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-y border-solid border-neutral-700"
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
:class="{
|
||||
'opacity-50': runner.resolved,
|
||||
'opacity-75': isLoading && runner.resolved
|
||||
}"
|
||||
>
|
||||
<td class="w-16 text-center">
|
||||
<td class="text-center w-16">
|
||||
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<Button
|
||||
class="mx-2 inline-block"
|
||||
class="inline-block mx-2"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
@@ -22,11 +22,11 @@
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="px-4 text-right">
|
||||
<td class="text-right px-4">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
|
||||
@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
@@ -113,50 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## Cloud nodes integration
|
||||
|
||||
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
### Production strictness
|
||||
|
||||
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
|
||||
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
|
||||
prevents the production deploy from silently shipping an out-of-date snapshot
|
||||
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
|
||||
and local builds continue to use the committed snapshot with a warning
|
||||
annotation.
|
||||
|
||||
### Required GitHub Actions / Vercel secrets
|
||||
|
||||
| Name | Where | Purpose |
|
||||
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
|
||||
|
||||
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
|
||||
`apps/website/src/data/cloud-nodes.snapshot.json` via
|
||||
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
|
||||
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
|
||||
`deploy-production` job hard-fails before `vercel build --prod` if the secret
|
||||
is missing.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
To update the committed snapshot manually (e.g. after onboarding new packs
|
||||
to Comfy Cloud):
|
||||
|
||||
```bash
|
||||
WEBSITE_CLOUD_API_KEY=… \
|
||||
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
git commit apps/website/src/data/cloud-nodes.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
|
||||
can't be accidentally committed. Otherwise the `Release: Website` GitHub
|
||||
Actions workflow runs the same step on every manual dispatch and opens a PR
|
||||
with the refreshed snapshot.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -190,4 +146,3 @@ renders the documented embed container.
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot
|
||||
|
||||
@@ -28,8 +28,7 @@ export default defineConfig({
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
|
||||
'/customers/series-entertainment/',
|
||||
'/zh-CN/terms-of-service': '/terms-of-service'
|
||||
'/customers/series-entertainment/'
|
||||
},
|
||||
build: {
|
||||
assets: '_website'
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH = '/affiliates/terms'
|
||||
|
||||
const SECTION_IDS = [
|
||||
'1-program-overview',
|
||||
'2-eligible-products',
|
||||
'3-commission-structure',
|
||||
'4-attribution-rules',
|
||||
'5-prohibited-activities',
|
||||
'6-content-guidelines',
|
||||
'7-termination',
|
||||
'8-program-modifications',
|
||||
'9-indemnification',
|
||||
'10-governing-law',
|
||||
'11-miscellaneous'
|
||||
] as const
|
||||
|
||||
test.describe('Affiliate Terms — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('exposes one anchor per legal section in order', async ({ page }) => {
|
||||
for (const id of SECTION_IDS) {
|
||||
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
|
||||
}
|
||||
|
||||
const orderedIds = await page.evaluate(
|
||||
(ids) => {
|
||||
const elements = ids
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((el): el is HTMLElement => el !== null)
|
||||
return elements
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const relation = a.compareDocumentPosition(b)
|
||||
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||
return 0
|
||||
})
|
||||
.map((el) => el.id)
|
||||
},
|
||||
[...SECTION_IDS]
|
||||
)
|
||||
expect(orderedIds).toEqual([...SECTION_IDS])
|
||||
})
|
||||
|
||||
test('renders an effective date footer', async ({ page }) => {
|
||||
await expect(page.getByText(/Effective Date:/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('skips internal-only sections (competitive analysis, open questions)', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByText(/Open questions for legal review/i)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliate Terms — desktop interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('clicking a desktop TOC link scrolls to the matching section', async ({
|
||||
page
|
||||
}) => {
|
||||
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
|
||||
await expect(desktopToc).toBeVisible()
|
||||
|
||||
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
|
||||
await link.click()
|
||||
|
||||
const target = page.locator('[id="5-prohibited-activities"]')
|
||||
await expect(target).toBeInViewport()
|
||||
})
|
||||
|
||||
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
|
||||
page
|
||||
}) => {
|
||||
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
|
||||
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.location.hash))
|
||||
.toBe('#7-termination')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliate Terms — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('shows a collapsed accordion TOC by default', async ({ page }) => {
|
||||
const accordion = page.locator('details', {
|
||||
has: page.getByText('On this page')
|
||||
})
|
||||
await expect(accordion).toBeVisible()
|
||||
await expect(accordion).not.toHaveAttribute('open', '')
|
||||
})
|
||||
|
||||
test('expanding the accordion reveals every section link', async ({
|
||||
page
|
||||
}) => {
|
||||
const accordion = page.locator('details', {
|
||||
has: page.getByText('On this page')
|
||||
})
|
||||
await accordion.locator('summary').click()
|
||||
await expect(accordion).toHaveAttribute('open', '')
|
||||
|
||||
for (const id of SECTION_IDS) {
|
||||
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('headings remain readable at narrow viewports without horizontal overflow', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: '1. Program Overview' })
|
||||
await expect(heading).toBeVisible()
|
||||
const box = await heading.boundingBox()
|
||||
expect(box, 'heading box').not.toBeNull()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
|
||||
})
|
||||
})
|
||||
@@ -1,200 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../src/components/affiliates/affiliateFaqs'
|
||||
import { programDetailRows } from '../src/components/affiliates/programDetails'
|
||||
import type { TranslationKey } from '../src/i18n/translations'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const FIRST_FAQ_QUESTION = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.q` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_FAQ_ANSWER = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.a` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_PROGRAM_DETAIL_LABEL = t(programDetailRows[0].labelKey, 'en')
|
||||
const FIRST_PROGRAM_DETAIL_VALUE = t(programDetailRows[0].valueKey, 'en')
|
||||
const PROGRAM_DETAIL_TABLE_ROW_COUNT = programDetailRows.length + 1
|
||||
|
||||
const PATH = '/affiliates'
|
||||
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
|
||||
|
||||
const SECTION_TESTIDS = [
|
||||
'affiliate-hero',
|
||||
'affiliate-trust-band',
|
||||
'affiliate-how-it-works',
|
||||
'affiliate-audience',
|
||||
'affiliate-program-details',
|
||||
'affiliate-brand-assets',
|
||||
'affiliate-footer-cta'
|
||||
] as const
|
||||
|
||||
test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders every page section in top-to-bottom order', async ({
|
||||
page
|
||||
}) => {
|
||||
const ys: number[] = []
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
ys.push(box!.y)
|
||||
}
|
||||
const sortedYs = [...ys].sort((a, b) => a - b)
|
||||
expect(ys).toEqual(sortedYs)
|
||||
})
|
||||
|
||||
test('renders the program details table on desktop', async ({ page }) => {
|
||||
const table = page.getByTestId('affiliate-program-details-table')
|
||||
await expect(table).toBeVisible()
|
||||
const rows = table.getByRole('row')
|
||||
await expect(rows).toHaveCount(PROGRAM_DETAIL_TABLE_ROW_COUNT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliates landing — desktop interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('emits FAQPage structured data with one entry per FAQ', async ({
|
||||
page
|
||||
}) => {
|
||||
const faqJsonLd = await page.evaluate(() => {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]'
|
||||
)
|
||||
)
|
||||
const match = scripts.find((s) =>
|
||||
(s.textContent ?? '').includes('FAQPage')
|
||||
)
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(AFFILIATE_FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('hero and footer CTAs target the application form in a new tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroCta = page.getByTestId('affiliate-hero-cta')
|
||||
await expect(heroCta).toBeVisible()
|
||||
await expect(heroCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(heroCta).toHaveAttribute('target', '_blank')
|
||||
await expect(heroCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const footerCta = page.getByTestId('affiliate-footer-cta-button')
|
||||
await expect(footerCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(footerCta).toHaveAttribute('target', '_blank')
|
||||
await expect(footerCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('footer links to the affiliate terms page as a same-tab navigation', async ({
|
||||
page
|
||||
}) => {
|
||||
const link = page
|
||||
.getByTestId('affiliate-footer-cta')
|
||||
.getByRole('link', { name: /Read the affiliate program terms/i })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toBeEnabled()
|
||||
await expect(link).toHaveAttribute('href', '/affiliates/terms')
|
||||
await expect(link).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
const popupPromise = context.waitForEvent('page')
|
||||
await page.getByTestId('affiliate-hero-cta').click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const popupUrl = popup.url()
|
||||
expect(
|
||||
popupUrl.includes('forms.gle/RS8L2ttcuGap4Q1v6') ||
|
||||
popupUrl.includes('docs.google.com/forms')
|
||||
).toBe(true)
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ_ANSWER)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Affiliates landing — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and primary CTA at narrow viewports', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('affiliate-hero-cta')).toBeVisible()
|
||||
})
|
||||
|
||||
test('program details collapse to a stacked definition list', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByTestId('affiliate-program-details-table')
|
||||
).toBeHidden()
|
||||
const detailsList = page
|
||||
.getByTestId('affiliate-program-details')
|
||||
.locator('dl')
|
||||
await expect(detailsList).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_LABEL)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_VALUE)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('all major sections remain visible without horizontal overflow', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,15 @@ test.describe('Careers page @smoke', () => {
|
||||
expect(await roles.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
@@ -54,21 +63,6 @@ test.describe('Careers page @smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page role links', () => {
|
||||
test('each role links to the Ashby job description page, not the application form', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/careers')
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
expect(href).not.toMatch(/\/application\/?$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Cloud nodes page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders at least one pack card', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
expect(await cards.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('search input filters cards down', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
const filteredCards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(filteredCards.first()).toBeVisible()
|
||||
const filteredCount = await filteredCards.count()
|
||||
|
||||
expect(filteredCount).toBeGreaterThan(0)
|
||||
expect(filteredCount).toBeLessThanOrEqual(initialCount)
|
||||
})
|
||||
|
||||
test('renders banner image or fallback element', async ({ page }) => {
|
||||
const banners = page.getByTestId('cloud-node-pack-banner')
|
||||
await expect(banners.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
|
||||
test('direct pack detail route renders node entries', async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-detail-node').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('search with no matches shows empty state', async ({ page }) => {
|
||||
await page
|
||||
.getByTestId('cloud-nodes-search')
|
||||
.fill('zzzz-this-pack-does-not-exist')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
|
||||
await expect(page.getByText('No matching packs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clearing search restores the full list', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('')
|
||||
await expect(cards).toHaveCount(initialCount)
|
||||
})
|
||||
|
||||
test('search matches against node display names, not just pack names', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-card-link').first()
|
||||
).toContainText('Impact Pack')
|
||||
})
|
||||
|
||||
test('switching sort to A → Z reorders cards alphabetically', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.locator('#cloud-nodes-sort').selectOption('az')
|
||||
const firstName = await page
|
||||
.getByTestId('cloud-node-pack-card-link')
|
||||
.first()
|
||||
.textContent()
|
||||
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
|
||||
})
|
||||
|
||||
test('list grid carries a localized aria-label', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('list', {
|
||||
name: 'Custom-node packs supported on Comfy Cloud'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking the back link returns to the index from a detail page', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await page.getByRole('link', { name: 'Back to all packs' }).click()
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('detail page renders publisher and external repo link', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
const repoLink = page.getByRole('link', {
|
||||
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
|
||||
})
|
||||
await expect(repoLink).toBeVisible()
|
||||
await expect(repoLink).toHaveAttribute('rel', /noopener/)
|
||||
})
|
||||
|
||||
test('unknown pack slug 404s', async ({ page }) => {
|
||||
const response = await page.goto(
|
||||
'/cloud/supported-nodes/this-pack-does-not-exist'
|
||||
)
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
|
||||
const jsonLd = page.locator('script[type="application/ld+json"]')
|
||||
const ldBlocks = await jsonLd.allTextContents()
|
||||
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
|
||||
})
|
||||
|
||||
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
|
||||
const ldBlocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
for (const block of ldBlocks) {
|
||||
expect(block).not.toContain('</script')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
|
||||
test('renders localized title and packs', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-banner').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Pricing page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud pricing teaser @smoke', () => {
|
||||
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud')
|
||||
await expect(
|
||||
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 99 KiB |
@@ -15,15 +15,11 @@
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
@@ -45,5 +41,88 @@
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
},
|
||||
"test:unit": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run"
|
||||
}
|
||||
},
|
||||
"test:coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "vitest run --coverage"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "playwright test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="147" height="159" viewBox="0 0 147 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M116.437 118.915C116.712 117.983 116.863 117 116.863 115.967C116.863 110.25 112.252 105.615 106.564 105.615H60.4108C57.9301 105.64 55.9006 103.625 55.9006 101.131C55.9006 100.678 55.9759 100.25 56.0761 99.8468L68.504 56.3212C69.0302 54.4069 70.7841 52.9963 72.8387 52.9963L119.168 52.946C128.94 52.946 137.182 46.3214 139.664 37.2788L146.63 13.0223C146.854 12.1658 146.98 11.2338 146.98 10.3019C146.98 4.60938 142.395 0 136.733 0H80.6814C70.9594 0 62.7409 6.57416 60.2104 15.5159L55.4998 32.0647C54.9485 33.9539 53.2197 35.3392 51.1651 35.3392H37.7098C28.0631 35.3392 19.9198 41.7875 17.3139 50.6287L0.375936 110.098C0.125241 110.98 0 111.937 0 112.894C0 118.612 4.61042 123.247 10.2981 123.247H23.5278C26.0085 123.247 28.038 125.262 28.038 127.781C28.038 128.209 27.988 128.637 27.8627 129.04L23.1771 145.438C22.9515 146.32 22.8012 147.226 22.8012 148.158C22.8012 153.851 27.3866 158.461 33.0492 158.461L89.1253 158.409C98.8722 158.409 107.091 151.81 109.596 142.819L116.412 118.94L116.437 118.915Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="142" height="142" rx="33" fill="#211927"/>
|
||||
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="142" height="142" rx="33" fill="#F2FF59"/>
|
||||
<path d="M91.7457 90.1697C91.8788 89.7195 91.9514 89.2449 91.9514 88.7461C91.9514 85.9841 89.7244 83.7452 86.9768 83.7452H64.6819C63.4836 83.7574 62.5032 82.784 62.5032 81.5794C62.5032 81.3604 62.5396 81.1536 62.588 80.9589L68.5914 59.9335C68.8456 59.0088 69.6928 58.3274 70.6853 58.3274L93.065 58.3031C97.7854 58.3031 101.767 55.103 102.966 50.7349L106.331 39.0176C106.439 38.6039 106.5 38.1537 106.5 37.7035C106.5 34.9537 104.285 32.7271 101.55 32.7271H74.4738C69.7775 32.7271 65.8075 35.9028 64.5851 40.2222L62.3096 48.2162C62.0433 49.1288 61.2082 49.798 60.2157 49.798H53.716C49.0561 49.798 45.1224 52.9129 43.8636 57.1837L35.6816 85.911C35.5605 86.3369 35.5 86.7993 35.5 87.2616C35.5 90.0236 37.7271 92.2625 40.4746 92.2625H46.8653C48.0636 92.2625 49.044 93.2359 49.044 94.4526C49.044 94.6595 49.0198 94.8663 48.9593 95.061L46.6959 102.982C46.5869 103.408 46.5143 103.846 46.5143 104.296C46.5143 107.046 48.7293 109.273 51.4647 109.273L78.5527 109.248C83.261 109.248 87.231 106.06 88.4414 101.717L91.7336 90.1818L91.7457 90.1697Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="148" height="159" viewBox="0 0 148 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M116.653 119.135C116.929 118.202 117.08 117.217 117.08 116.183C117.08 110.454 112.461 105.811 106.762 105.811H60.523C58.0377 105.836 56.0044 103.817 56.0044 101.319C56.0044 100.865 56.0798 100.436 56.1802 100.032L68.6312 56.4258C69.1584 54.508 70.9155 53.0947 72.9739 53.0947L119.389 53.0443C129.179 53.0443 137.437 46.4074 139.924 37.348L146.903 13.0464C147.127 12.1884 147.253 11.2547 147.253 10.321C147.253 4.61794 142.659 0 136.987 0H80.8312C71.0912 0 62.8574 6.58636 60.3222 15.5448L55.6028 32.1242C55.0505 34.017 53.3185 35.4049 51.2601 35.4049H37.7798C28.1152 35.4049 19.9568 41.8651 17.346 50.7227L0.376634 110.303C0.125474 111.186 0 112.145 0 113.104C0 118.832 4.61899 123.476 10.3173 123.476H23.5715C26.0568 123.476 28.0901 125.495 28.0901 128.018C28.0901 128.447 28.0399 128.876 27.9144 129.28L23.2202 145.708C22.9941 146.591 22.8435 147.5 22.8435 148.433C22.8435 154.137 27.4374 158.755 33.1106 158.755L89.2908 158.704C99.0558 158.704 107.29 152.092 109.8 143.084L116.628 119.16L116.653 119.135Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" rx="12" fill="#211927"/>
|
||||
<path fill="#F2FF59" d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
3
apps/website/public/icons/clients/EA.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -31,4 +31,28 @@ Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "Comfy",
|
||||
"short_name": "Comfy",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,396 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WORKFLOW_TEMPLATES_BASE =
|
||||
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
|
||||
|
||||
const TEMPLATES_DIR = fileURLToPath(
|
||||
new URL('../../../../workflow_templates/templates', import.meta.url)
|
||||
)
|
||||
|
||||
const QUANT_SUFFIXES = [
|
||||
'_fp8_e4m3fn_scaled',
|
||||
'_fp8_e4m3fn',
|
||||
'_fp8_scaled',
|
||||
'_fp4_mixed',
|
||||
'_fp8mixed',
|
||||
'_fp8',
|
||||
'_fp16',
|
||||
'_fp4',
|
||||
'_bf16',
|
||||
'_int8'
|
||||
]
|
||||
|
||||
interface RawModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
interface ModelData {
|
||||
url: string
|
||||
directory: string
|
||||
templates: Set<string>
|
||||
firstTemplate?: string
|
||||
}
|
||||
|
||||
interface OutputModel {
|
||||
slug: string
|
||||
name: string
|
||||
huggingFaceUrl: string
|
||||
directory: string
|
||||
workflowCount: number
|
||||
displayName: string
|
||||
docsUrl?: string
|
||||
thumbnailUrl?: string
|
||||
canonicalSlug?: string
|
||||
}
|
||||
|
||||
// Maps api_*.json filename prefix to a canonical display name and slug.
|
||||
// Add entries here as new partner integrations land in workflow_templates.
|
||||
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
nano: { name: 'Nano Banana', slug: 'nano-banana' },
|
||||
kling: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
kling2: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
|
||||
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
google: { name: 'Gemini Image', slug: 'gemini-image' },
|
||||
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
|
||||
ideogram: { name: 'Ideogram', slug: 'ideogram' },
|
||||
pixverse: { name: 'Pixverse', slug: 'pixverse' },
|
||||
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
|
||||
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
|
||||
bria: { name: 'Bria AI', slug: 'bria-ai' },
|
||||
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
|
||||
recraft: { name: 'Recraft', slug: 'recraft' },
|
||||
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
|
||||
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
|
||||
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
|
||||
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
|
||||
wan: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
wan2: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
veo2: { name: 'Veo 2', slug: 'veo-2' },
|
||||
veo3: { name: 'Veo 3', slug: 'veo-3' },
|
||||
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
|
||||
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
|
||||
function stripQuant(base: string): string {
|
||||
for (const suffix of QUANT_SUFFIXES) {
|
||||
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function makeSlug(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.toLowerCase()
|
||||
.replace(/[_.]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function makeDisplayName(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.split(/[_-]/)
|
||||
.map((part) => {
|
||||
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
|
||||
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
|
||||
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
|
||||
return part.charAt(0).toUpperCase() + part.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function extractModels(
|
||||
obj: unknown,
|
||||
templateName: string,
|
||||
models: Map<string, ModelData>
|
||||
): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModels(item, templateName, models)
|
||||
return
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] !== 'string') continue
|
||||
|
||||
const name = model['name']
|
||||
const url = typeof model['url'] === 'string' ? model['url'] : ''
|
||||
const directory =
|
||||
typeof model['directory'] === 'string' ? model['directory'] : ''
|
||||
|
||||
if (!models.has(name)) {
|
||||
models.set(name, {
|
||||
url,
|
||||
directory,
|
||||
templates: new Set(),
|
||||
firstTemplate: templateName
|
||||
})
|
||||
}
|
||||
models.get(name)!.templates.add(templateName)
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
extractModels(value, templateName, models)
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiModelData {
|
||||
slug: string
|
||||
name: string
|
||||
directory: 'partner_nodes'
|
||||
templateCount: number
|
||||
}
|
||||
|
||||
function extractApiModels(files: string[]): ApiModelData[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('api_')) continue
|
||||
const prefix = file.slice(4).split('_')[0]
|
||||
const entry = API_PROVIDER_MAP[prefix]
|
||||
if (!entry) continue
|
||||
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].map(([slug, count]) => {
|
||||
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
|
||||
return {
|
||||
slug,
|
||||
name: found.name,
|
||||
directory: 'partner_nodes' as const,
|
||||
templateCount: count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reads all locale index.json files to build a map of
|
||||
// raw model filename → tutorialUrl. Index entries name the template file;
|
||||
// that file's embedded model objects give the actual filenames.
|
||||
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const indexFiles = readdirSync(templatesDir).filter(
|
||||
(f) =>
|
||||
f.startsWith('index') &&
|
||||
f.endsWith('.json') &&
|
||||
!f.includes('schema') &&
|
||||
!f.includes('logo')
|
||||
)
|
||||
// Collect template-name → tutorialUrl from all locale indexes (first wins)
|
||||
const templateTutorialMap = new Map<string, string>()
|
||||
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
|
||||
for (const file of sorted) {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(data)) continue
|
||||
for (const cat of data as unknown[]) {
|
||||
if (typeof cat !== 'object' || cat === null) continue
|
||||
const templates = (cat as Record<string, unknown>)['templates']
|
||||
if (!Array.isArray(templates)) continue
|
||||
for (const t of templates) {
|
||||
if (typeof t !== 'object' || t === null) continue
|
||||
const entry = t as Record<string, unknown>
|
||||
const tutorialUrl =
|
||||
typeof entry['tutorialUrl'] === 'string'
|
||||
? entry['tutorialUrl']
|
||||
: undefined
|
||||
const templateName =
|
||||
typeof entry['name'] === 'string' ? entry['name'] : undefined
|
||||
if (
|
||||
tutorialUrl &&
|
||||
templateName &&
|
||||
!templateTutorialMap.has(templateName)
|
||||
) {
|
||||
templateTutorialMap.set(templateName, tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each template with a tutorialUrl, open the template file and map
|
||||
// every embedded model filename to that tutorialUrl
|
||||
for (const [templateName, tutorialUrl] of templateTutorialMap) {
|
||||
const filePath = join(templatesDir, `${templateName}.json`)
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
function extractModelNames(obj: unknown): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModelNames(item)
|
||||
return
|
||||
}
|
||||
const record = obj as Record<string, unknown>
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
|
||||
map.set(model['name'], tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
extractModelNames(value)
|
||||
}
|
||||
}
|
||||
|
||||
extractModelNames(data)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function templateThumbnailUrl(
|
||||
firstTemplate: string | undefined,
|
||||
templatesDir: string
|
||||
): string | undefined {
|
||||
if (!firstTemplate) return undefined
|
||||
const base = firstTemplate.replace(/\.json$/, '')
|
||||
const localPath = join(templatesDir, `${base}-1.webp`)
|
||||
if (!existsSync(localPath)) return undefined
|
||||
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const models = new Map<string, ModelData>()
|
||||
|
||||
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(TEMPLATES_DIR, file)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
const data: unknown = JSON.parse(raw)
|
||||
extractModels(data, file, models)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiModels = extractApiModels(files)
|
||||
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
|
||||
|
||||
const sorted = [...models.entries()].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
|
||||
// Build quant convergence map
|
||||
const groups = new Map<string, Array<[string, ModelData]>>()
|
||||
for (const [name, data] of sorted) {
|
||||
const base = stripExt(name)
|
||||
const canonicalBase = stripQuant(base)
|
||||
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
|
||||
groups.get(canonicalBase)!.push([name, data])
|
||||
}
|
||||
|
||||
const canonicalMap = new Map<string, string | null>()
|
||||
for (const members of groups.values()) {
|
||||
if (members.length > 1) {
|
||||
const membersSorted = [...members].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
const canonicalName = membersSorted[0][0]
|
||||
canonicalMap.set(canonicalName, null)
|
||||
for (const [name] of membersSorted.slice(1)) {
|
||||
canonicalMap.set(name, canonicalName)
|
||||
}
|
||||
} else {
|
||||
canonicalMap.set(members[0][0], null)
|
||||
}
|
||||
}
|
||||
|
||||
const output: OutputModel[] = sorted.map(([name, data]) => {
|
||||
const canonicalRaw = canonicalMap.get(name) ?? null
|
||||
const result: OutputModel = {
|
||||
slug: makeSlug(name),
|
||||
name,
|
||||
huggingFaceUrl: data.url,
|
||||
directory: data.directory,
|
||||
workflowCount: data.templates.size,
|
||||
displayName: makeDisplayName(name)
|
||||
}
|
||||
const docsUrl = tutorialUrlMap.get(name)
|
||||
if (docsUrl) result.docsUrl = docsUrl
|
||||
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
|
||||
if (thumb) result.thumbnailUrl = thumb
|
||||
if (canonicalRaw !== null) {
|
||||
result.canonicalSlug = makeSlug(canonicalRaw)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const apiOutput: OutputModel[] = apiModels
|
||||
.sort((a, b) => b.templateCount - a.templateCount)
|
||||
.map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
huggingFaceUrl: '',
|
||||
directory: m.directory,
|
||||
workflowCount: m.templateCount,
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
` ${withThumbs}/${combined.length} models have thumbnails\n`
|
||||
)
|
||||
|
||||
const defaultOut = join(
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
'../src/config/generated-models.json'
|
||||
)
|
||||
const outputArg = process.argv[2] ?? defaultOut
|
||||
const json = JSON.stringify(combined, null, 2) + '\n'
|
||||
|
||||
writeFileSync(outputArg, json, 'utf8')
|
||||
process.stdout.write(
|
||||
`Written ${combined.length} models ` +
|
||||
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
run()
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
|
||||
|
||||
writeFileSync(tempPath, serialized, 'utf8')
|
||||
renameSync(tempPath, snapshotPath)
|
||||
|
||||
const totalNodes = outcome.snapshot.packs.reduce(
|
||||
(n, pack) => n + pack.nodes.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<GlassCard
|
||||
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
|
||||
>
|
||||
|
||||
@@ -74,7 +74,7 @@ useHeroAnimation({
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/about/co-founders.webm"
|
||||
poster="https://media.comfy.org/website/about/co-founders-poster.webp"
|
||||
|
||||
@@ -33,7 +33,7 @@ const values: {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto max-w-5xl text-center">
|
||||
<SectionLabel>
|
||||
{{ t('about.values.label', locale) }}
|
||||
|
||||
@@ -16,7 +16,7 @@ const investors = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<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"
|
||||
|
||||
@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
|
||||
<template #right-card>
|
||||
<img
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const audienceKeys: TranslationKey[] = [
|
||||
'affiliate-landing.audience.item.0',
|
||||
'affiliate-landing.audience.item.1',
|
||||
'affiliate-landing.audience.item.2',
|
||||
'affiliate-landing.audience.item.3',
|
||||
'affiliate-landing.audience.item.4'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-audience"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.audience.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<ul class="mx-auto mt-12 flex max-w-3xl flex-col gap-4">
|
||||
<li
|
||||
v-for="key in audienceKeys"
|
||||
:key="key"
|
||||
class="text-primary-comfy-canvas flex items-start gap-4 text-base md:text-lg"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span>{{ t(key, locale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import { brandAssets } from './brandAssets'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-brand-assets"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.assets.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p
|
||||
class="text-primary-comfy-canvas/70 mx-auto mt-4 max-w-2xl text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.assets.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
<ul
|
||||
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="asset in brandAssets"
|
||||
:key="asset.id"
|
||||
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col overflow-hidden rounded-4xl border"
|
||||
:data-testid="`affiliate-asset-${asset.id}`"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-comfy-ink/40 flex aspect-video items-center justify-center overflow-hidden p-6"
|
||||
>
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="t(asset.titleKey, locale)"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-5">
|
||||
<h3 class="text-primary-comfy-canvas text-base font-light">
|
||||
{{ t(asset.titleKey, locale) }}
|
||||
</h3>
|
||||
<a
|
||||
:href="asset.download"
|
||||
:download="asset.download.split('/').pop()"
|
||||
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
|
||||
>
|
||||
{{ t('affiliate-landing.assets.downloadLabel', locale) }}
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-secondary-mauve/30 border-primary-comfy-canvas/10 mt-12 border-t px-6 py-20 text-center md:py-28"
|
||||
data-testid="affiliate-footer-cta"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl lg:text-5xl"
|
||||
>
|
||||
{{ t('affiliate-landing.footerCta.heading', locale) }}
|
||||
</h2>
|
||||
<div class="mt-8 flex flex-col items-center gap-4">
|
||||
<BrandButton
|
||||
:href="externalLinks.affiliateApplicationForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
|
||||
data-testid="affiliate-footer-cta-button"
|
||||
class="px-8 py-4 text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.cta.apply', locale) }}
|
||||
</BrandButton>
|
||||
<a
|
||||
:href="routes.affiliateTerms"
|
||||
class="text-primary-comfy-canvas/70 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{{ t('affiliate-landing.footerCta.termsLink', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const highlightKeys: TranslationKey[] = [
|
||||
'affiliate-landing.hero.highlight.0',
|
||||
'affiliate-landing.hero.highlight.1',
|
||||
'affiliate-landing.hero.highlight.2',
|
||||
'affiliate-landing.hero.highlight.3'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-stretch gap-10 px-6 pt-12 pb-16 lg:flex-row lg:items-center lg:gap-16 lg:px-20 lg:pt-20 lg:pb-24"
|
||||
data-testid="affiliate-hero"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
|
||||
>
|
||||
{{ t('affiliate-landing.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-comfy-yellow mt-4 text-2xl font-light md:text-3xl lg:text-4xl"
|
||||
>
|
||||
{{ t('affiliate-landing.hero.subheading', locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas/80 mt-6 max-w-xl text-base">
|
||||
{{ t('affiliate-landing.hero.body', locale) }}
|
||||
</p>
|
||||
<ul class="mt-6 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="key in highlightKeys"
|
||||
:key="key"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3 text-base"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span>{{ t(key, locale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-8">
|
||||
<BrandButton
|
||||
:href="externalLinks.affiliateApplicationForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
|
||||
data-testid="affiliate-hero-cta"
|
||||
class="px-8 py-4 text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.cta.apply', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center"
|
||||
data-testid="affiliate-hero-media"
|
||||
>
|
||||
<slot name="media">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/showcase/ui-overview.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
class="w-full max-w-xl rounded-4xl"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps: { titleKey: TranslationKey; bodyKey: TranslationKey }[] = [
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.0.title',
|
||||
bodyKey: 'affiliate-landing.how.step.0.body'
|
||||
},
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.1.title',
|
||||
bodyKey: 'affiliate-landing.how.step.1.body'
|
||||
},
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.2.title',
|
||||
bodyKey: 'affiliate-landing.how.step.2.body'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-how-it-works"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.how.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<ol
|
||||
class="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3 md:gap-8"
|
||||
>
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.titleKey"
|
||||
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col gap-4 rounded-4xl border p-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<h3 class="text-primary-comfy-canvas text-2xl font-light">
|
||||
{{ t(step.titleKey, locale) }}
|
||||
</h3>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm">
|
||||
{{ t(step.bodyKey, locale) }}
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import { programDetailRows } from './programDetails'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-program-details"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.details.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<div class="mx-auto mt-12 max-w-3xl">
|
||||
<div class="hidden md:block">
|
||||
<table
|
||||
class="w-full border-collapse text-left"
|
||||
data-testid="affiliate-program-details-table"
|
||||
>
|
||||
<thead>
|
||||
<tr class="border-primary-comfy-canvas/20 border-b">
|
||||
<th
|
||||
scope="col"
|
||||
class="text-primary-comfy-yellow py-4 pr-6 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('affiliate-landing.details.headerLabel', locale) }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="text-primary-comfy-yellow py-4 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('affiliate-landing.details.headerValue', locale) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in programDetailRows"
|
||||
:key="row.labelKey"
|
||||
class="border-primary-comfy-canvas/10 border-b"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
class="text-primary-comfy-canvas py-5 pr-6 text-base font-light"
|
||||
>
|
||||
{{ t(row.labelKey, locale) }}
|
||||
</th>
|
||||
<td class="text-primary-comfy-canvas/80 py-5 text-base">
|
||||
{{ t(row.valueKey, locale) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<dl class="flex flex-col gap-6 md:hidden">
|
||||
<div
|
||||
v-for="row in programDetailRows"
|
||||
:key="row.labelKey"
|
||||
class="border-primary-comfy-canvas/10 flex flex-col gap-1 border-b pb-4"
|
||||
>
|
||||
<dt
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(row.labelKey, locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas text-base">
|
||||
{{ t(row.valueKey, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<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="border-primary-comfy-canvas/10 border-y px-6 py-10 text-center md:py-12"
|
||||
data-testid="affiliate-trust-band"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas text-sm tracking-wider uppercase md:text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.trust.label', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
export const AFFILIATE_FAQ_PREFIX = 'affiliate-landing.faq'
|
||||
export const AFFILIATE_FAQ_HEADING_KEY: TranslationKey =
|
||||
'affiliate-landing.faq.heading'
|
||||
export const AFFILIATE_FAQ_COUNT = 8
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasKey, t, translationKeys } from '../../i18n/translations'
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_HEADING_KEY,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from './affiliateFaqs'
|
||||
import { brandAssets } from './brandAssets'
|
||||
|
||||
const PREFIX = 'affiliate-landing'
|
||||
|
||||
const EXPECTED_SECTION_PREFIXES = [
|
||||
'page',
|
||||
'cta',
|
||||
'hero',
|
||||
'trust',
|
||||
'how',
|
||||
'audience',
|
||||
'details',
|
||||
'assets',
|
||||
'faq',
|
||||
'footerCta'
|
||||
] as const
|
||||
|
||||
const HERO_HIGHLIGHT_COUNT = 4
|
||||
const HOW_STEP_COUNT = 3
|
||||
const AUDIENCE_ITEM_COUNT = 5
|
||||
const DETAILS_ROW_COUNT = 6
|
||||
|
||||
const INTERNAL_KEY_PATTERNS = [
|
||||
/open-questions/,
|
||||
/todo/i,
|
||||
/draft/i,
|
||||
/placeholder/i,
|
||||
/internal/i
|
||||
]
|
||||
|
||||
function affiliateKeys(): string[] {
|
||||
return translationKeys.filter((k) => k.startsWith(`${PREFIX}.`))
|
||||
}
|
||||
|
||||
describe('affiliate landing i18n', () => {
|
||||
it('exposes the canonical top-level section prefixes', () => {
|
||||
const keys = affiliateKeys()
|
||||
for (const section of EXPECTED_SECTION_PREFIXES) {
|
||||
const hit = keys.some((k) => k.startsWith(`${PREFIX}.${section}.`))
|
||||
expect(hit, `missing section: ${section}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('orders sections as the page renders them', () => {
|
||||
const keys = affiliateKeys()
|
||||
const seenSections: string[] = []
|
||||
for (const key of keys) {
|
||||
const section = key.split('.')[1]
|
||||
if (!section) continue
|
||||
if (!seenSections.includes(section)) seenSections.push(section)
|
||||
}
|
||||
const orderedExpected = EXPECTED_SECTION_PREFIXES.filter((s) =>
|
||||
seenSections.includes(s)
|
||||
)
|
||||
const orderedActual = seenSections.filter((s) =>
|
||||
(EXPECTED_SECTION_PREFIXES as readonly string[]).includes(s)
|
||||
)
|
||||
expect(orderedActual).toEqual([...orderedExpected])
|
||||
})
|
||||
|
||||
it('exposes hero, page, and cta keys editors will need', () => {
|
||||
expect(hasKey(`${PREFIX}.page.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.page.description`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.cta.apply`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.cta.applyAriaLabel`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.subheading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.body`)).toBe(true)
|
||||
for (let i = 0; i < HERO_HIGHLIGHT_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.hero.highlight.${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the trust band, how-it-works, and audience copy', () => {
|
||||
expect(hasKey(`${PREFIX}.trust.label`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.how.heading`)).toBe(true)
|
||||
for (let i = 0; i < HOW_STEP_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.how.step.${i}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.how.step.${i}.body`)).toBe(true)
|
||||
}
|
||||
expect(hasKey(`${PREFIX}.audience.heading`)).toBe(true)
|
||||
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.audience.item.${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the program details rows', () => {
|
||||
expect(hasKey(`${PREFIX}.details.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.headerLabel`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.headerValue`)).toBe(true)
|
||||
for (let i = 0; i < DETAILS_ROW_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.details.row.${i}.label`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.row.${i}.value`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches every brand-asset tile to a translation key', () => {
|
||||
expect(hasKey(`${PREFIX}.assets.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.assets.subheading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.assets.downloadLabel`)).toBe(true)
|
||||
for (const asset of brandAssets) {
|
||||
expect(hasKey(asset.titleKey)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes every 1-indexed faq.<n>.q/a pair from 1 to AFFILIATE_FAQ_COUNT (FAQSection contract)', () => {
|
||||
expect(AFFILIATE_FAQ_PREFIX).toBe(`${PREFIX}.faq`)
|
||||
expect(hasKey(AFFILIATE_FAQ_HEADING_KEY)).toBe(true)
|
||||
for (let n = 1; n <= AFFILIATE_FAQ_COUNT; n++) {
|
||||
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.q`)).toBe(true)
|
||||
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.a`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps AFFILIATE_FAQ_COUNT in sync with the actual faq.<n>.q keys in translations', () => {
|
||||
const faqQuestionKeyPattern = new RegExp(
|
||||
`^${AFFILIATE_FAQ_PREFIX}\\.(\\d+)\\.q$`
|
||||
)
|
||||
const indices = translationKeys
|
||||
.map((k) => k.match(faqQuestionKeyPattern)?.[1])
|
||||
.filter((m): m is string => m !== undefined)
|
||||
.map((s) => parseInt(s, 10))
|
||||
.sort((a, b) => a - b)
|
||||
expect(indices).toEqual(
|
||||
Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => i + 1)
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the footer cta copy', () => {
|
||||
expect(hasKey(`${PREFIX}.footerCta.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.footerCta.termsLink`)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns non-empty english copy for every affiliate-landing key', () => {
|
||||
for (const key of affiliateKeys()) {
|
||||
expect(t(key as never, 'en').trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not leak internal-only keys (drafts, todos, open questions)', () => {
|
||||
const leaks = affiliateKeys().filter((k) =>
|
||||
INTERNAL_KEY_PATTERNS.some((re) => re.test(k))
|
||||
)
|
||||
expect(leaks).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface BrandAsset {
|
||||
id: string
|
||||
titleKey: TranslationKey
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
export const brandAssets: BrandAsset[] = [
|
||||
{
|
||||
id: 'logo-horizontal',
|
||||
titleKey: 'affiliate-landing.assets.tile.logo-horizontal.title',
|
||||
download: '/icons/logo.svg',
|
||||
preview: '/icons/logo.svg'
|
||||
},
|
||||
{
|
||||
id: 'logomark',
|
||||
titleKey: 'affiliate-landing.assets.tile.logomark.title',
|
||||
download: '/icons/logomark.svg',
|
||||
preview: '/icons/logomark.svg'
|
||||
},
|
||||
{
|
||||
id: 'comfy-full-logo-yellow',
|
||||
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-yellow.title',
|
||||
download: '/affiliates/brand/comfy-full-logo-yellow.svg',
|
||||
preview: '/affiliates/brand/comfy-full-logo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'comfy-full-logo-ink',
|
||||
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-ink.title',
|
||||
download: '/affiliates/brand/comfy-full-logo-ink.svg',
|
||||
preview: '/affiliates/brand/comfy-full-logo-ink.svg'
|
||||
},
|
||||
{
|
||||
id: 'amplified-logo-mark',
|
||||
titleKey: 'affiliate-landing.assets.tile.amplified-logo-mark.title',
|
||||
download: '/affiliates/brand/comfy-amplified-logo-mark.svg',
|
||||
preview: '/affiliates/brand/comfy-amplified-logo-mark.svg'
|
||||
},
|
||||
{
|
||||
id: 'dimensional-logo-mark',
|
||||
titleKey: 'affiliate-landing.assets.tile.dimensional-logo-mark.title',
|
||||
download: '/affiliates/brand/comfy-dimensional-logo-mark.svg',
|
||||
preview: '/affiliates/brand/comfy-dimensional-logo-mark.svg'
|
||||
},
|
||||
{
|
||||
id: 'color-combo-yellow',
|
||||
titleKey: 'affiliate-landing.assets.tile.color-combo-yellow.title',
|
||||
download: '/affiliates/brand/comfy-color-combo-yellow.svg',
|
||||
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'color-combo-ink',
|
||||
titleKey: 'affiliate-landing.assets.tile.color-combo-ink.title',
|
||||
download: '/affiliates/brand/comfy-color-combo-ink.svg',
|
||||
preview: '/affiliates/brand/comfy-color-combo-ink.svg'
|
||||
}
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface ProgramDetailRow {
|
||||
labelKey: TranslationKey
|
||||
valueKey: TranslationKey
|
||||
}
|
||||
|
||||
export const programDetailRows: ProgramDetailRow[] = [
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.0.label',
|
||||
valueKey: 'affiliate-landing.details.row.0.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.1.label',
|
||||
valueKey: 'affiliate-landing.details.row.1.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.2.label',
|
||||
valueKey: 'affiliate-landing.details.row.2.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.3.label',
|
||||
valueKey: 'affiliate-landing.details.row.3.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.4.label',
|
||||
valueKey: 'affiliate-landing.details.row.4.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.5.label',
|
||||
valueKey: 'affiliate-landing.details.row.5.value'
|
||||
}
|
||||
]
|
||||
@@ -87,8 +87,8 @@ function scrollToDepartment(deptKey: string) {
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:gap-20">
|
||||
<div class="shrink-0 lg:min-w-48">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<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"
|
||||
>
|
||||
@@ -130,44 +130,33 @@ function scrollToDepartment(deptKey: string) {
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.jobUrl"
|
||||
:href="role.applyUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 hover:border-primary-comfy-canvas group flex items-center gap-4 border-b py-5 transition-colors duration-200"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col md:flex-row md:items-baseline md:gap-x-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
|
||||
>
|
||||
{{ role.title }}
|
||||
</span>
|
||||
<div
|
||||
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm md:mt-0 md:contents"
|
||||
>
|
||||
<span>{{ role.department }}</span>
|
||||
<span class="md:hidden">{{ role.location }}</span>
|
||||
</div>
|
||||
<span class="text-primary-warm-gray ml-3 text-sm">
|
||||
{{ role.department }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-primary-warm-gray hidden shrink-0 text-sm md:inline"
|
||||
>
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow/0 group-hover:bg-primary-comfy-yellow relative grid size-7 shrink-0 place-items-center rounded-sm transition-colors duration-300 ease-out"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors duration-300 ease-out"
|
||||
style="
|
||||
mask: url('/icons/arrow-up-right.svg') center / contain
|
||||
no-repeat;
|
||||
"
|
||||
<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"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
|
||||
>
|
||||
{{ t('cloudNodes.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
|
||||
{{ t('cloudNodes.hero.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PackNode } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', nodes } = defineProps<{
|
||||
locale?: Locale
|
||||
nodes: readonly PackNode[]
|
||||
}>()
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details
|
||||
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
|
||||
>
|
||||
<summary
|
||||
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
|
||||
>
|
||||
{{ t('cloudNodes.card.nodesHeading', locale) }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-5">
|
||||
<div
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h4
|
||||
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ group.category }}
|
||||
</h4>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="text-primary-comfy-canvas text-sm/relaxed"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useBannerImage } from '../../composables/useBannerImage'
|
||||
|
||||
const { bannerUrl, iconUrl, name } = defineProps<{
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
DEFAULT_BANNER,
|
||||
isImageError,
|
||||
showDefaultBanner,
|
||||
imgSrc,
|
||||
onImageError
|
||||
} = useBannerImage({
|
||||
bannerUrl: () => bannerUrl,
|
||||
iconUrl: () => iconUrl
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="z-0 aspect-7/3 w-full overflow-hidden"
|
||||
data-testid="cloud-node-pack-banner"
|
||||
>
|
||||
<div v-if="showDefaultBanner" class="size-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="`${name} banner`"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full">
|
||||
<div
|
||||
v-if="imgSrc && !isImageError"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
|
||||
/>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="`${name} banner`"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative z-10 size-full object-cover'
|
||||
: 'relative z-10 size-full object-contain'
|
||||
"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import NodeList from './NodeList.vue'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { locale = 'en', pack } = defineProps<{
|
||||
locale?: Locale
|
||||
pack: Pack
|
||||
}>()
|
||||
|
||||
const detailHref =
|
||||
locale === 'zh-CN'
|
||||
? `/zh-CN/cloud/supported-nodes/${pack.id}`
|
||||
: `/cloud/supported-nodes/${pack.id}`
|
||||
|
||||
function nodeCountLabel(nodeCount: number): string {
|
||||
const key =
|
||||
new Intl.PluralRules(locale).select(nodeCount) === 'one'
|
||||
? 'cloudNodes.card.nodeCountOne'
|
||||
: 'cloudNodes.card.nodeCountOther'
|
||||
return t(key, locale).replace('{count}', String(nodeCount))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
|
||||
data-testid="cloud-node-pack-card"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
|
||||
<a
|
||||
:href="detailHref"
|
||||
class="hover:text-primary-comfy-yellow"
|
||||
data-testid="cloud-node-pack-card-link"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-primary-warm-gray text-sm/relaxed">
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</a>
|
||||
<span v-else class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray">•</span>
|
||||
<span class="text-primary-comfy-canvas">{{
|
||||
nodeCountLabel(pack.nodes.length)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,203 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber
|
||||
} from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { pack, locale = 'en' } = defineProps<{
|
||||
pack: Pack
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const backHref =
|
||||
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="px-6 pb-20 md:px-20 md:pb-28"
|
||||
data-testid="cloud-node-pack-detail"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<a
|
||||
:href="backHref"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.detail.back', locale) }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-7 p-5 md:p-6">
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
|
||||
>
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</dt>
|
||||
<dd>
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ pack.repoUrl }}
|
||||
</a>
|
||||
<span v-else class="text-primary-comfy-canvas">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.publisher', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.downloads', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.downloads, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.stars', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.githubStars, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.latestVersion', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.latestVersion || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.license', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.license || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.lastUpdated', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
|
||||
>
|
||||
{{ t('cloudNodes.detail.nodesHeading', locale) }}
|
||||
</h2>
|
||||
|
||||
<section
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="border-primary-warm-gray/20 rounded-2xl border p-4"
|
||||
>
|
||||
<h3 class="text-primary-comfy-canvas text-base font-semibold">
|
||||
{{ group.category }}
|
||||
</h3>
|
||||
<ul class="mt-3 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="border-primary-warm-gray/20 rounded-xl border p-3"
|
||||
data-testid="cloud-node-pack-detail-node"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-sm font-semibold"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.experimental"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.experimental', locale) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.deprecated"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.deprecated', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="node.description"
|
||||
class="text-primary-warm-gray mt-2 text-sm/relaxed"
|
||||
>
|
||||
{{ node.description }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useFilteredPacks } from '../../composables/useFilteredPacks'
|
||||
import type { PackSortMode } from '../../composables/useFilteredPacks'
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import PackCard from './PackCard.vue'
|
||||
|
||||
const { locale = 'en', packs } = defineProps<{
|
||||
locale?: Locale
|
||||
packs: readonly Pack[]
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { default: '' })
|
||||
const sortMode = ref<PackSortMode>('downloads')
|
||||
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs: () => packs,
|
||||
query,
|
||||
sortMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pb-20 md:px-20 md:pb-32">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
|
||||
>
|
||||
{{ t('cloudNodes.section.heading', locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<label for="cloud-nodes-search" class="sr-only">
|
||||
{{ t('cloudNodes.search.label', locale) }}
|
||||
</label>
|
||||
<input
|
||||
id="cloud-nodes-search"
|
||||
v-model="query"
|
||||
:placeholder="t('cloudNodes.search.placeholder', locale)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
|
||||
)
|
||||
"
|
||||
data-testid="cloud-nodes-search"
|
||||
/>
|
||||
|
||||
<label for="cloud-nodes-sort" class="sr-only">
|
||||
{{ t('cloudNodes.sort.label', locale) }}
|
||||
</label>
|
||||
<select
|
||||
id="cloud-nodes-sort"
|
||||
v-model="sortMode"
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
|
||||
}"
|
||||
>
|
||||
<option value="downloads">
|
||||
{{ t('cloudNodes.sort.downloads', locale) }}
|
||||
</option>
|
||||
<option value="mostNodes">
|
||||
{{ t('cloudNodes.sort.mostNodes', locale) }}
|
||||
</option>
|
||||
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
|
||||
<option value="recentlyUpdated">
|
||||
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="filteredPacks.length === 0"
|
||||
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas block text-base font-semibold">
|
||||
{{ t('cloudNodes.empty.heading', locale) }}
|
||||
</span>
|
||||
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
role="list"
|
||||
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
|
||||
>
|
||||
<PackCard
|
||||
v-for="pack in filteredPacks"
|
||||
:key="pack.id"
|
||||
:pack="pack"
|
||||
:locale="locale"
|
||||
role="listitem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -9,14 +9,12 @@ import { brandButtonVariants } from './brandButton.variants'
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
variant,
|
||||
size,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
variant?: BrandButtonVariants['variant']
|
||||
size?: BrandButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
@@ -28,7 +26,6 @@ const {
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href
|
||||
:target
|
||||
:rel
|
||||
:class="cn(brandButtonVariants({ variant, size }), customClass)"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
|
||||
@@ -18,7 +18,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex w-full scrollbar-none items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
class="scrollbar-none flex items-center gap-3 overflow-x-auto lg:flex-col lg:overflow-x-hidden"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -41,7 +41,7 @@ function toggle(index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type NavDropdownItem = {
|
||||
export type NavDropdownItem = {
|
||||
label: string
|
||||
href: string
|
||||
badge?: string
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
v-if="i > 0"
|
||||
src="/icons/node-union.svg"
|
||||
alt=""
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -72,7 +72,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ 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-secondary-cool-gray')
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
|
||||
]
|
||||
|
||||
const cards = excludeProduct
|
||||
@@ -46,9 +46,7 @@ const cards = excludeProduct
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-center px-4 text-center">
|
||||
<SectionLabel v-if="labelKey">
|
||||
|
||||
@@ -67,10 +67,6 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.affiliateProgram', locale),
|
||||
href: routes.affiliates
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -86,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
const contactColumn = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -95,11 +91,6 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.cloudStatus', locale),
|
||||
href: externalLinks.cloudStatus,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const logos = [
|
||||
'Amazon Studios',
|
||||
'Apple',
|
||||
'Autodesk',
|
||||
'EA',
|
||||
'Harman',
|
||||
'Hp',
|
||||
'Lucid',
|
||||
|
||||
@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
|
||||
<section class="px-6 py-16 lg:px-16 lg:py-24">
|
||||
<!-- Scrollable track -->
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
|
||||
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
|
||||
>
|
||||
<div
|
||||
v-for="(fb, i) in feedbacks"
|
||||
|
||||
@@ -72,7 +72,7 @@ function handleLogoLoad() {
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/customers/blackmath/video.webm"
|
||||
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"
|
||||
|
||||
@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
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"
|
||||
|
||||
@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
|
||||
<section class="px-6 py-16 lg:px-20 lg:py-40">
|
||||
<VideoPlayer
|
||||
src="https://media.comfy.org/website/customers/silverside/video.webm"
|
||||
poster="https://media.comfy.org/website/customers/silverside/poster.webp"
|
||||
|
||||
@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
|
||||
const {
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryItemAttribution from './GalleryItemAttribution.vue'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
items,
|
||||
@@ -251,7 +251,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Thumbnail strip -->
|
||||
<div
|
||||
class="mx-auto mt-6 h-16 max-w-full scrollbar-none overflow-x-auto px-6 lg:h-30"
|
||||
class="scrollbar-none mx-auto mt-6 h-16 max-w-full overflow-x-auto px-6 lg:h-30"
|
||||
>
|
||||
<div class="flex items-end gap-3">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
item,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { visibleGalleryItems as items } from '../../data/gallery'
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryCard from './GalleryCard.vue'
|
||||
import GalleryDetailModal from './GalleryDetailModal.vue'
|
||||
@@ -18,6 +16,166 @@ function openDetail(index: number) {
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
export interface GalleryItem {
|
||||
image?: string
|
||||
video?: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins - Kyrie Irving',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
|
||||
title: 'Neon Nights',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'MidJourney man',
|
||||
teamAlias: 'DOGSTUDIO/DEPT®',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
|
||||
title: 'Eat It - Dance',
|
||||
userAlias: 'Johana Lyu',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.joannalyu.com/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
|
||||
title: 'Fall',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
|
||||
title: 'Untitled',
|
||||
userAlias: 'Nathan Shipley',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
|
||||
title: 'Origami world',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
|
||||
title: 'Shot on InstaX',
|
||||
userAlias: 'Karen X',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.instagram.com/karenxcheng/'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
|
||||
title: "It's gonna be a good good summer",
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685900'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
|
||||
title: 'DDU-DU DDU-DU',
|
||||
userAlias: 'Purz',
|
||||
teamAlias: 'Andidea',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://vimeo.com/1019924290'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
|
||||
title: 'Cuco - A Love Letter To LA',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: 'CoffeeVectors',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://vimeo.com/1062859798'
|
||||
},
|
||||
{
|
||||
video:
|
||||
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
|
||||
title: 'Show you my garden',
|
||||
userAlias: 'Paul Trillo',
|
||||
teamAlias: '',
|
||||
tool: 'CogvideoX',
|
||||
href: 'https://vimeo.com/1019685479'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
|
||||
title: 'Goodbye Beijing',
|
||||
userAlias: 'Rui',
|
||||
teamAlias: 'makeitrad',
|
||||
tool: 'Animatediff',
|
||||
href: 'https://x.com/rui40000'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
|
||||
title: 'Animation Reel',
|
||||
userAlias: 'Andidea',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'ComfyUI',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Desktop layout pattern (repeating):
|
||||
* Row A: full-width (1 item)
|
||||
@@ -65,10 +223,7 @@ while (idx < items.length) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="gallery-grid"
|
||||
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
|
||||
>
|
||||
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
|
||||
<!-- Desktop grid -->
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"
|
||||
|
||||
@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
|
||||
>
|
||||
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
|
||||
<SectionLabel>
|
||||
{{ t('gallery.label', locale) }}
|
||||
</SectionLabel>
|
||||
|
||||