Compare commits
2 Commits
fix/error-
...
feat/load3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c67e826a | ||
|
|
c0feaad4f6 |
@@ -1,695 +0,0 @@
|
||||
---
|
||||
name: bug-dump-ingest
|
||||
description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.'
|
||||
---
|
||||
|
||||
# Bug Dump Ingest
|
||||
|
||||
**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically.
|
||||
|
||||
```text
|
||||
fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals
|
||||
→ POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call)
|
||||
→ poll slack_read_thread → parse Linear bot card for FE-NNNN + URL
|
||||
→ POST :white_check_mark: confirmation thread reply via slack_send_message
|
||||
→ if verification = "real bug" AND no dedupe AND no open PR:
|
||||
invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply
|
||||
```
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt.
|
||||
2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`.
|
||||
3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt.
|
||||
|
||||
### What the skill cannot do
|
||||
|
||||
The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection.
|
||||
|
||||
## Team emoji scheme
|
||||
|
||||
| Emoji | Meaning | Who adds it | Skill behavior |
|
||||
| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- |
|
||||
| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps |
|
||||
| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row |
|
||||
| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification |
|
||||
| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue |
|
||||
|
||||
## Design Priority
|
||||
|
||||
Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list.
|
||||
2. **Fetch** — `slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies.
|
||||
3. **Filter** — drop already-processed (see Processed Detection).
|
||||
4. **Classify** — bug / discussion / meta (see Classification Rules).
|
||||
5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create.
|
||||
6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification).
|
||||
7. **Extract** — normalize to ticket schema (see Ticket Schema).
|
||||
8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix).
|
||||
9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute.
|
||||
10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one.
|
||||
11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: <URL>` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log.
|
||||
12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract.
|
||||
13. **Log** — append to session log; update `processed.json`.
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Source channel | `#bug-dump` (`C0A4XMHANP3`) |
|
||||
| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. |
|
||||
| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` |
|
||||
| State dir | `~/temp/bug-dump-ingest/` |
|
||||
| Processed registry | `~/temp/bug-dump-ingest/processed.json` |
|
||||
| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` |
|
||||
| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually |
|
||||
|
||||
## Label Taxonomy
|
||||
|
||||
Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use:
|
||||
|
||||
| Label kind | Values | Source |
|
||||
| ------------ | ------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `source:` | `source:bug-dump` | Always (marks Slack sync) |
|
||||
| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics |
|
||||
| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics |
|
||||
| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics |
|
||||
| `reporter:` | `reporter:<slack-handle>` (kebab-case) | From message author |
|
||||
| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable |
|
||||
|
||||
Label rules:
|
||||
|
||||
- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`.
|
||||
- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix".
|
||||
- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo.
|
||||
- `regression` — set when the bug mentions a version/upgrade correlation.
|
||||
- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker.
|
||||
|
||||
Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title.
|
||||
|
||||
## Processed Detection
|
||||
|
||||
A top-level message is considered already-handled (skip creation) if ANY of:
|
||||
|
||||
- Its timestamp appears in `processed.json`.
|
||||
- It carries a `:white_check_mark:` reaction on the parent — ticket already created.
|
||||
- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue.
|
||||
- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log.
|
||||
- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up.
|
||||
- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`).
|
||||
- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user.
|
||||
- It is a system/meta message (`has joined the channel`, bot-only message).
|
||||
- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip".
|
||||
|
||||
Never re-ingest a message already marked in any of the above ways.
|
||||
|
||||
Filter query for Slack search-based sweeps:
|
||||
|
||||
```text
|
||||
in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD
|
||||
```
|
||||
|
||||
## False-Defect Verification
|
||||
|
||||
Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes.
|
||||
|
||||
| Check | Command / Signal | Demote-to |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------- | ---------- |
|
||||
| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` |
|
||||
| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` |
|
||||
| Fix PR merged on main | `gh search prs "in:title <keyword>" --state merged --limit 3` | `fixed` |
|
||||
| Fix PR open (already-filed) | `gh search prs "<keyword>" --state open --limit 3` | `pr-open` |
|
||||
| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` |
|
||||
| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` |
|
||||
| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` |
|
||||
| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` |
|
||||
|
||||
For each demoted candidate, record the demotion reason in the approval table as `Verify: <tag>` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion.
|
||||
|
||||
### Recommended verify commands
|
||||
|
||||
```bash
|
||||
# 1. Search recent PRs for the feature in question
|
||||
gh search prs "<keyword>" --repo Comfy-Org/ComfyUI_frontend --limit 5
|
||||
|
||||
# 2. Grep for the feature / component mentioned
|
||||
rg -l "<ComponentOrFeatureName>" src/ apps/
|
||||
|
||||
# 3. Check if it's a known i18n / documented setting
|
||||
rg "<setting-key>" src/locales/en/ docs/
|
||||
```
|
||||
|
||||
Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck.
|
||||
|
||||
## Classification Rules
|
||||
|
||||
For each unprocessed top-level message, decide:
|
||||
|
||||
| Class | Signal | Action |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket |
|
||||
| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip |
|
||||
| **question** | User asking if something is expected or known | Skip unless answered = bug |
|
||||
| **meta** | Channel joins, bot messages, cross-posts without content | Skip |
|
||||
| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link |
|
||||
|
||||
When ambiguous, default to **bug** and let the human decide in the approval batch.
|
||||
|
||||
## Ticket Schema
|
||||
|
||||
Normalize each bug to this shape before presenting:
|
||||
|
||||
```json
|
||||
{
|
||||
"slack_ts": "1776639963.837519",
|
||||
"slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519",
|
||||
"reporter": "Ali Ranjah (wavey)",
|
||||
"title": "Unet model dropdown missing selected model",
|
||||
"description": "Body with repro steps, env, attachments list, thread summary",
|
||||
"env": ["cloud prod"],
|
||||
"severity": "low | medium | high",
|
||||
"area": "ui | node-system | workflow | cloud | templates | unknown",
|
||||
"attachments": [{ "name": "...", "id": "F...", "type": "image/png" }],
|
||||
"thread_resolution": "solved | open | none"
|
||||
}
|
||||
```
|
||||
|
||||
Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: <permalink>". Attach thread summary only if it adds context beyond the top-level message.
|
||||
|
||||
### Severity Heuristics
|
||||
|
||||
- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset").
|
||||
- **medium** — visible regression, template error, wrong pricing, broken UX on a common path.
|
||||
- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue.
|
||||
|
||||
When unsure, mark `medium` and flag for human in the approval batch.
|
||||
|
||||
### Area Heuristics
|
||||
|
||||
- `ui` — visual glitches, palette issues, popover clipping, dropdown styling.
|
||||
- `node-system` — canvas perf, reroute, node drag, widget rendering, undo.
|
||||
- `workflow` — template failures, save/load, refresh regressions.
|
||||
- `cloud` — jobs, pricing, assets, auth, queue.
|
||||
- `templates` — specific template errors.
|
||||
|
||||
## Pre-flight Dedupe Gate (MANDATORY)
|
||||
|
||||
Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict.
|
||||
|
||||
### Check 1 — Open Linear issues (via `@Linear search`)
|
||||
|
||||
Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
})
|
||||
```
|
||||
|
||||
Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class.
|
||||
|
||||
If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_search_public({
|
||||
query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
|
||||
})
|
||||
```
|
||||
|
||||
This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log.
|
||||
|
||||
Treat a hit as a duplicate if any of:
|
||||
|
||||
- Title overlap ≥ 80% (after lowercasing + stopword removal)
|
||||
- Same reporter + same component reference in description
|
||||
- Same stack trace or error code
|
||||
|
||||
**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket.
|
||||
|
||||
### Check 2 — Open or merged fix PRs on GitHub
|
||||
|
||||
```bash
|
||||
# Open PRs matching title keywords
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \
|
||||
--search "<keyword-1> <keyword-2>" --limit 5 \
|
||||
--json number,title,url,createdAt
|
||||
|
||||
# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship"
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
|
||||
--search "<keyword-1> <keyword-2> merged:>=<YYYY-MM-DD>" --limit 5 \
|
||||
--json number,title,url,mergedAt
|
||||
```
|
||||
|
||||
Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation.
|
||||
|
||||
**Verdict:**
|
||||
|
||||
- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix).
|
||||
- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land).
|
||||
|
||||
### Failure handling
|
||||
|
||||
If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets.
|
||||
|
||||
Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit.
|
||||
|
||||
## Interactive Approval
|
||||
|
||||
Present candidates in batches of 5-10. Table format (10 columns):
|
||||
|
||||
```text
|
||||
# | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec
|
||||
----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+-----
|
||||
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N
|
||||
2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y
|
||||
3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L
|
||||
4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open
|
||||
```
|
||||
|
||||
Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation.
|
||||
|
||||
### Default recommendation logic
|
||||
|
||||
The skill computes `Rec` deterministically from the gate results:
|
||||
|
||||
- `L` — Dedupe hit on open Linear issue.
|
||||
- `pr-open` — Open GitHub PR hit.
|
||||
- `fixed` — Merged PR hit within the reporter's observation window.
|
||||
- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only.
|
||||
- `?` — Repro incomplete or classification ambiguous.
|
||||
- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path.
|
||||
- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix.
|
||||
|
||||
### Response format
|
||||
|
||||
- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow.
|
||||
- `S` — **skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes.
|
||||
- `N` — skip entirely (log reason in session file).
|
||||
- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent.
|
||||
- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match).
|
||||
- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent.
|
||||
- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks).
|
||||
- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default.
|
||||
|
||||
Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body.
|
||||
|
||||
## Linear Slack Bot Integration (@Linear)
|
||||
|
||||
Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today).
|
||||
- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team.
|
||||
- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently.
|
||||
|
||||
### Create an issue
|
||||
|
||||
For each approved `Y` candidate, call:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- First line MUST be `@Linear create` — this is the command token.
|
||||
- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team.
|
||||
- `Status: Triage` pins the initial state (per § System Context).
|
||||
- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use.
|
||||
- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction.
|
||||
- Use real newlines (not literal `\n`) when constructing the text.
|
||||
|
||||
After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for:
|
||||
|
||||
- An `FE-NNNN` identifier
|
||||
- A `https://linear.app/<org>/issue/FE-NNNN` URL
|
||||
|
||||
The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply).
|
||||
|
||||
### Search (dedupe)
|
||||
|
||||
See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside.
|
||||
|
||||
### Link an existing issue (`L` response)
|
||||
|
||||
When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear link FE-4521"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches.
|
||||
|
||||
### Label / status updates
|
||||
|
||||
When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link):
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 add-labels pr-open"
|
||||
})
|
||||
```
|
||||
|
||||
Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body.
|
||||
|
||||
### Captured fields per create
|
||||
|
||||
Every successful create must produce, via the Linear bot's reply card:
|
||||
|
||||
- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log
|
||||
- `url` — `https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply
|
||||
- `ts` of the Linear bot's card reply — recorded in session log for audit
|
||||
|
||||
If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value.
|
||||
|
||||
### Failure path
|
||||
|
||||
If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`):
|
||||
|
||||
1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply.
|
||||
2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL:
|
||||
```text
|
||||
:warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN.
|
||||
```
|
||||
3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference).
|
||||
4. Log the failure in the session log.
|
||||
|
||||
Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card.
|
||||
|
||||
## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL
|
||||
|
||||
Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread:
|
||||
|
||||
1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration.
|
||||
2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card.
|
||||
|
||||
The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug.
|
||||
|
||||
The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested.
|
||||
|
||||
### Required call shape
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509"
|
||||
text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection.
|
||||
- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`.
|
||||
- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug.
|
||||
- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text.
|
||||
- Capture the returned `ts` and record it in the session log for audit.
|
||||
|
||||
### NEVER-do list (common failure mode)
|
||||
|
||||
- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead.
|
||||
- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply.
|
||||
- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages.
|
||||
- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail.
|
||||
|
||||
### Fix-path reply (after red-green-fix opens a PR)
|
||||
|
||||
When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<same parent ts>",
|
||||
text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>"
|
||||
})
|
||||
```
|
||||
|
||||
Same "tool call, not chat output" rule applies.
|
||||
|
||||
### Parent reaction — optional visibility nudge (not on critical path)
|
||||
|
||||
The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human:
|
||||
|
||||
```text
|
||||
Optional: add :white_check_mark: to parent messages for in-channel visibility.
|
||||
LIN-4710 → <permalink>
|
||||
LIN-4711 → <permalink>
|
||||
```
|
||||
|
||||
This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion.
|
||||
|
||||
## Fix Workflow (auto-invoke red-green-fix)
|
||||
|
||||
For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff.
|
||||
|
||||
### Required Skill tool call
|
||||
|
||||
```text
|
||||
Skill({
|
||||
skill: "red-green-fix",
|
||||
args: "<composed prompt — see below>"
|
||||
})
|
||||
```
|
||||
|
||||
Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue:
|
||||
|
||||
```text
|
||||
Bug: <title>
|
||||
Linear: <LIN-ID> (<LINEAR_URL>)
|
||||
Source: Slack <permalink>
|
||||
Reporter: <display-name>
|
||||
Env: <env tags>
|
||||
Area: <area>
|
||||
Branch: fix/<lin-id-lowercase>-<short-slug>
|
||||
|
||||
Repro:
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
Expected: <expected behavior>
|
||||
Actual: <actual behavior>
|
||||
|
||||
Test layer (inferred from area):
|
||||
- ui → Vitest colocated + Playwright e2e tagged @regression
|
||||
- node-system → Playwright e2e primarily
|
||||
- workflow / templates → Playwright e2e
|
||||
- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend"
|
||||
|
||||
Test naming:
|
||||
- describe('<LIN-ID>: <one-line bug summary>', ...)
|
||||
- Playwright test title must include the LIN-ID.
|
||||
|
||||
PR body must include:
|
||||
- "Fixes <LIN-ID>"
|
||||
- "Source: Slack <permalink>"
|
||||
|
||||
Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit.
|
||||
```
|
||||
|
||||
The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic.
|
||||
|
||||
### Verifying the invocation ran
|
||||
|
||||
After the `Skill` call returns, the skill MUST confirm at least one of:
|
||||
|
||||
1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`).
|
||||
2. A PR URL is present in `red-green-fix`'s return payload.
|
||||
|
||||
If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply.
|
||||
|
||||
### Inputs summary
|
||||
|
||||
- **Bug description** — the Linear description (includes repro, env, source permalink).
|
||||
- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`.
|
||||
- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`).
|
||||
- **Test layer** — inferred from `area`:
|
||||
- `ui` → unit (Vitest) + e2e (Playwright)
|
||||
- `node-system` → e2e primarily; unit if isolable
|
||||
- `workflow` / `templates` → e2e
|
||||
- `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo"
|
||||
|
||||
### Handoff-Exclusion list (do NOT auto-invoke red-green-fix)
|
||||
|
||||
These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why:
|
||||
|
||||
- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?"
|
||||
- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`.
|
||||
- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing.
|
||||
- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only.
|
||||
- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision).
|
||||
- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`).
|
||||
|
||||
When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`.
|
||||
|
||||
### Test authoring rules
|
||||
|
||||
Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion:
|
||||
|
||||
- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID:
|
||||
|
||||
```typescript
|
||||
// src/components/node/UnetDropdown.test.ts
|
||||
describe('LIN-4710: unet dropdown missing selected model', () => {
|
||||
it('includes the currently-selected model in the list even when not in available models', () => {
|
||||
// ...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title:
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
'LIN-4710 unet dropdown regression',
|
||||
{ tag: ['@regression'] },
|
||||
() => {
|
||||
test('keeps selected model visible in the dropdown', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`.
|
||||
|
||||
(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.)
|
||||
|
||||
### PR body template
|
||||
|
||||
The red-green-fix skill's PR template is extended with a `Source` line:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
<Root cause>
|
||||
|
||||
- Fixes LIN-NNN
|
||||
- Source: Slack <permalink>
|
||||
|
||||
## Red-Green Verification
|
||||
|
||||
| Commit | CI Status | Purpose |
|
||||
| ------------------------------------------ | -------------------- | ------------------------------- |
|
||||
| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug |
|
||||
| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit regression test passes locally
|
||||
- [ ] E2E regression test passes locally (if UI)
|
||||
- [ ] Manual repro no longer reproduces
|
||||
- [ ] Linear ticket linked
|
||||
```
|
||||
|
||||
After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply).
|
||||
|
||||
## Emoji Reaction Hints (read-only)
|
||||
|
||||
The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary):
|
||||
|
||||
| Reaction | Meaning | Action |
|
||||
| -------------------- | ------------------ | -------------------------------------------------------- |
|
||||
| `:white_check_mark:` | Ticket created | Skip — already ingested |
|
||||
| `:pr-open:` | PR open | Skip creation; record PR link in session log |
|
||||
| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking |
|
||||
| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log |
|
||||
|
||||
Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these:
|
||||
|
||||
| Reaction | Meaning | Action |
|
||||
| -------- | ------------------- | -------------------------------------------------- |
|
||||
| `:eyes:` | Someone is triaging | Still ingestable |
|
||||
| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row |
|
||||
| `:+1:` | Acknowledged | Ignore |
|
||||
|
||||
Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID.
|
||||
|
||||
## Session Log
|
||||
|
||||
Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`:
|
||||
|
||||
```text
|
||||
Bug Dump Ingest Session -- 2026-04-20 11:40 KST
|
||||
|
||||
Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST
|
||||
Scanned: 28 top-level messages
|
||||
Skipped (meta/discussion/processed): 14
|
||||
Proposed: 14
|
||||
Approved: 11
|
||||
Created in Linear: 10
|
||||
Draft-only (creation failed): 1
|
||||
Linked-only (dedupe): 1
|
||||
Thread replies posted: 11
|
||||
|
||||
Created:
|
||||
- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui
|
||||
- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud
|
||||
- ...
|
||||
|
||||
Skipped with reason:
|
||||
- 1776592837.616399 -- design discussion in thread, not a bug
|
||||
- ...
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Thread summaries, not raw dumps
|
||||
|
||||
Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter.
|
||||
|
||||
### Cross-posts are not bugs
|
||||
|
||||
When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself.
|
||||
|
||||
### Resolved-in-thread messages
|
||||
|
||||
If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket.
|
||||
|
||||
### Permalinks
|
||||
|
||||
Construct Slack permalinks as:
|
||||
|
||||
```text
|
||||
https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED}
|
||||
```
|
||||
|
||||
E.g. `1776510375.473579` → `p1776510375473579`.
|
||||
|
||||
### Attachment handling
|
||||
|
||||
Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread.
|
||||
|
||||
### No auto-create without approval
|
||||
|
||||
Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status).
|
||||
- `reference/schema.md` — full ticket schema with field-by-field extraction notes.
|
||||
- `reference/examples.md` — worked examples drawn from real #bug-dump messages.
|
||||
- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof.
|
||||
- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed.
|
||||
- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill.
|
||||
@@ -1,123 +0,0 @@
|
||||
# Worked Examples
|
||||
|
||||
Real #bug-dump messages (2026-04-17 → 2026-04-20) normalized through the skill.
|
||||
|
||||
## Example 1 — Clean bug with repro
|
||||
|
||||
**Source message** (wavey, 2026-04-20 08:06):
|
||||
|
||||
> unet model dropdown doesnt display all available models, think this is part of a larger issue with model dropdowns..
|
||||
>
|
||||
> • open flux.2 klein 4b image edit template
|
||||
> • open unet drop down --> notice selected model isnt present in the list, even though its selected
|
||||
> • execute (to check if it flags the model as missing) --> notice it still runs
|
||||
> No action needed, this is solved
|
||||
|
||||
**Thread resolution**: "No action needed, this is solved" — reporter resolved it in the same message.
|
||||
|
||||
**Classification**: bug, but `thread_resolution = solved`. Flag for human.
|
||||
|
||||
**Approval row**:
|
||||
|
||||
```text
|
||||
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud | low | ui | N | N (reporter marked solved)
|
||||
```
|
||||
|
||||
Default recommendation: `N`. If human overrides to `Y`, file with a "Regression test" label so QA still tracks it.
|
||||
|
||||
---
|
||||
|
||||
## Example 2 — Clear high-severity cloud bug
|
||||
|
||||
**Source message** (Denys Puziak, 2026-04-18 05:45):
|
||||
|
||||
> I see two reports about jobs ending in 30 minutes while the user is on the Pro plan
|
||||
> cc @Hunter
|
||||
> https://discord.com/channels/.../1494078128971055145
|
||||
|
||||
**Classification**: bug, `env: [cloud prod]` (Pro plan = cloud), `severity: high` (paying users), `area: cloud`.
|
||||
|
||||
**Proposed title**: `Pro plan jobs end at 30 minutes`
|
||||
|
||||
**Description** (excerpt):
|
||||
|
||||
```markdown
|
||||
**Reporter:** Denys Puziak
|
||||
**Env:** cloud prod
|
||||
**Severity (proposed):** high
|
||||
**Area:** cloud
|
||||
|
||||
## Repro
|
||||
|
||||
1. User on Pro plan submits a job
|
||||
2. Job ends at 30 minutes instead of the Pro plan limit
|
||||
|
||||
## Notes
|
||||
|
||||
- Two user reports aggregated by Denys
|
||||
- cc'd @Hunter
|
||||
|
||||
## Source
|
||||
|
||||
Slack: <permalink>
|
||||
Discord thread: https://discord.com/channels/.../1494078128971055145
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 3 — Not a bug (discussion)
|
||||
|
||||
**Source message** (Christian Byrne, 2026-04-19 19:00):
|
||||
|
||||
> @Glary-Bot okay option A is clearly superior and I feel embarrassed I didn't see that line myself...
|
||||
|
||||
**Classification**: discussion (design review chatter). Skip. Log reason in session file.
|
||||
|
||||
---
|
||||
|
||||
## Example 4 — Meta-action / PR planning
|
||||
|
||||
**Source message** (Christian Byrne, 2026-04-19 09:30):
|
||||
|
||||
> @Glary-Bot how about we make a PR to do:
|
||||
>
|
||||
> 1. Audit the rest of the codebase...
|
||||
> 2. Create a helper in src/base...
|
||||
|
||||
**Classification**: discussion (PR-plan proposal). Skip.
|
||||
|
||||
---
|
||||
|
||||
## Example 5 — Performance regression
|
||||
|
||||
**Source message** (Terry Jia, 2026-04-18 12:52):
|
||||
|
||||
> With Nodes 2.0, large workflows (hundreds of nodes) make the canvas extremely laggy and unusable for actual work — switching tabs takes several seconds or more. Switching back to Litegraph, performance is significantly better.
|
||||
|
||||
**Classification**: bug, `area: node-system`, `severity: high`.
|
||||
|
||||
**Dedupe**: Post `@Linear search nodes 2.0 performance canvas lag` (Team: Frontend Engineering, Status: open) in the candidate's thread. Likely matches exist — flag `Dedup? ?` and ask human which ticket to link to.
|
||||
|
||||
---
|
||||
|
||||
## Example 6 — Reporter says it's a question, not a report
|
||||
|
||||
**Source message** (Luke, 2026-04-17 08:27):
|
||||
|
||||
> Is NodeInfo supposed to show information or docs about the node? It just brings up the node sidebar
|
||||
|
||||
**Classification**: question → ambiguous. Read thread. If replies confirm "that's unexpected, should show docs", upgrade to bug. If "yes that's intended", skip.
|
||||
|
||||
Default recommendation in the approval batch: `?` (needs expansion).
|
||||
|
||||
---
|
||||
|
||||
## Example 7 — Bug with PR already in flight
|
||||
|
||||
**Source message** (Pablo, 2026-04-17 08:52):
|
||||
|
||||
> when deleting multiple assets on cloud -> the confirmation popup still has the assets hashes as names instead of the display name
|
||||
|
||||
**Reaction**: `pr-open (1)` — someone's opened a PR.
|
||||
|
||||
**Classification**: `already-filed` branch. Skip creation; in the session log, note "PR already open". If the human wants a tracking Linear ticket anyway, still fileable with a link to the PR.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Linear Slack Bot (@Linear) Reference
|
||||
|
||||
The skill drives Linear exclusively through the Linear Slack app (`@Linear`). **There is no Linear MCP, no `LINEAR_API_KEY`, no GraphQL.** Every Linear read/write happens as a Slack message that mentions `@Linear` in the `#bug-dump` thread, and the Linear Slack app performs the action and posts a reply card containing the issue URL.
|
||||
|
||||
## Why Slack-only
|
||||
|
||||
- The `#bug-dump` thread is already the source of truth; keeping the entire lifecycle (report → ticket → PR → resolution) in one thread means Processed Detection can grep the thread instead of a separate registry.
|
||||
- No API key rotation, no MCP server install, no OAuth browser flow — works on any machine that already has the Slack MCP configured.
|
||||
- The Linear Slack app's reply card (with issue URL, title, status, and assignee) IS the canonical receipt; the skill records its `ts` in the session log.
|
||||
|
||||
## Prerequisites (one-time, per workspace)
|
||||
|
||||
The Comfy Slack workspace must already have the Linear Slack app installed (it is — that's how humans use `@Linear` reactions today) and `#bug-dump` (channel `C0A4XMHANP3`) must have Linear enabled for the `Frontend Engineering` team. Nothing else to configure. If a `@Linear` invocation silently does nothing, the bot isn't present in the channel — surface that to the human rather than re-trying.
|
||||
|
||||
## Supported operations
|
||||
|
||||
Every operation is a `mcp__plugin_slack_slack__slack_send_message` call with `channel_id=C0A4XMHANP3` and `thread_ts=<parent-ts>`. The `text` is a natural-language instruction to the Linear bot. Keep the text concise — Linear parses the first line as the command intent.
|
||||
|
||||
### 1. Create an issue from the thread
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description body>\n\nSource: <slack-permalink>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Start with `@Linear create` on its own line — this is the command token the bot keys on.
|
||||
- Always specify `Team: Frontend Engineering`. Without it, the bot falls back to the Slack workspace's default team, which may not be FE.
|
||||
- `Status: Triage` pins the initial workflow state.
|
||||
- `Labels:` — comma-separated. If a label doesn't exist yet in Linear, the bot creates it on first use (verified in Linear workspace settings). Keep the taxonomy exactly as SKILL.md § Label Taxonomy.
|
||||
- `<description body>` — markdown per `reference/schema.md` Description Template. Use real newlines, not literal `\n`.
|
||||
- End with `Source: <slack-permalink>` so the Linear issue body links back even if the auto-attachment of the parent message fails.
|
||||
|
||||
The Linear bot replies in the same thread with a card that contains:
|
||||
|
||||
- The Linear URL (`https://linear.app/comfy-org/issue/FE-NNNN`)
|
||||
- Status, assignee (initially unassigned), and applied labels
|
||||
- A "View in Linear" button
|
||||
|
||||
Parse the URL out of the bot's reply text (or attachments). If no card reply appears within ~10s of polling `slack_read_thread`, treat it as a creation failure — do NOT proceed to the `:white_check_mark:` confirmation reply.
|
||||
|
||||
### 2. Search existing open issues (dedupe)
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with a card listing up to ~5 matching open issues. Parse identifier (`FE-NNNN`) and URL per row. Treat a hit as a duplicate per SKILL.md § Pre-flight Dedupe Gate § Check 1.
|
||||
|
||||
If `@Linear search` is not supported in the installed Slack app version, fall back to Slack-native search across the `#bug-dump` thread replies (previous `@Linear` cards contain title + URL — grep those for the same keywords). Record which path was used in the session log so the human can see dedupe coverage.
|
||||
|
||||
### 3. Link an existing issue (dedupe: `L` response)
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear link FE-4521"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with the linked issue card. The skill then posts its own `:white_check_mark: Linked to Linear: <URL>` confirmation reply (see SKILL.md § Slack Thread Reply).
|
||||
|
||||
### 4. Add labels to an existing issue
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 add-labels pr-open"
|
||||
})
|
||||
```
|
||||
|
||||
Used when an open PR is discovered after ticket creation and the Linear issue should flip to `pr-open`.
|
||||
|
||||
### 5. Change status
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 status In Progress"
|
||||
})
|
||||
```
|
||||
|
||||
Rarely used by the skill directly — usually status changes come from the `red-green-fix` PR lifecycle (Linear auto-moves to `In Review` when a PR references `Fixes FE-4521`).
|
||||
|
||||
## Description body template
|
||||
|
||||
The text that follows the command headers is rendered verbatim as the Linear issue description (markdown). Use this template — see `reference/schema.md` for field-by-field extraction notes:
|
||||
|
||||
```markdown
|
||||
**Reporter:** <slack-display-name>
|
||||
**Env:** cloud prod / local / electron / ...
|
||||
**Severity (proposed):** high/medium/low
|
||||
**Area:** ui / node-system / workflow / cloud / templates
|
||||
|
||||
## Repro
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## Expected
|
||||
|
||||
...
|
||||
|
||||
## Actual
|
||||
|
||||
...
|
||||
|
||||
## Attachments (in Slack thread)
|
||||
|
||||
- image.png (png, 315 KB)
|
||||
- Screen Recording.mov (mov, 37 MB)
|
||||
|
||||
## Source
|
||||
|
||||
Slack: <permalink>
|
||||
Thread summary: <1-3 bullets if thread adds context>
|
||||
```
|
||||
|
||||
The Slack permalink is load-bearing — it's the canonical route to attachments, reporter, and any follow-up discussion. Do NOT embed Slack file IDs (`F0AT...`) directly; they're permissioned.
|
||||
|
||||
## Parsing the bot's reply
|
||||
|
||||
After each `slack_send_message` that mentions `@Linear`, poll `slack_read_thread` (with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`) up to 3 times, ~3s apart. Scan replies authored by the Linear Slack app user for:
|
||||
|
||||
- Any `https://linear.app/<org>/issue/FE-\d+` URL → capture as the issue URL.
|
||||
- The `FE-NNNN` identifier pattern → capture as the issue identifier.
|
||||
- An error phrase (`couldn't`, `failed`, `not found`, `no team matched`) → treat as failure; surface the full bot text to the human.
|
||||
|
||||
Record the bot reply's `ts` alongside the captured URL and identifier in the session log.
|
||||
|
||||
## Failure modes & handling
|
||||
|
||||
| Symptom | Likely cause | Handling |
|
||||
| ------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No bot reply within 10s | Linear app not in channel, or bot outage | Halt the batch, surface to human, do NOT fabricate a Linear URL. Remaining approved candidates stay queued for re-run. |
|
||||
| Bot replies with "no team matched" | Team name typo or Linear workspace drift | Re-send with the exact team name from the Linear workspace (default: `Frontend Engineering`). If it still fails, ask the human to verify. |
|
||||
| Bot replies with "couldn't parse labels" | One of the labels has syntax the bot rejects | Drop the offending label, re-send; log the partial-label failure so the human can patch after. |
|
||||
| Bot creates the issue but reply lacks the URL | Rare bot format change | Re-fetch the thread after ~5s; if URL still absent, open Linear search via `@Linear search <title>` and recover the identifier + URL. |
|
||||
| Multiple `@Linear` replies match (duplicate card) | The skill retried without polling first | Keep the earliest card's URL; log the extras. Never re-issue `@Linear create` for the same candidate without confirming the first card failed. |
|
||||
|
||||
Never retry `@Linear create` without first running `@Linear search` for the same title keywords — a duplicate card is worse than an initial failure because the human has to close one of them manually.
|
||||
|
||||
## Why no direct API path
|
||||
|
||||
- The Linear MCP (official or community) would require either OAuth setup or `LINEAR_API_KEY` in env — both are per-machine hurdles the skill should not depend on.
|
||||
- Direct GraphQL against `api.linear.app` has the same key-management cost and bypasses the Slack thread as the audit trail.
|
||||
- Routing every action through `@Linear` in the thread gives humans full visibility in the channel (the bot's card is the receipt) and Processed Detection becomes a simple Slack thread read.
|
||||
|
||||
If a future need requires capabilities the `@Linear` Slack app doesn't expose (bulk operations, private field edits, webhooks), stop and surface the limitation to the human rather than quietly adding an API-key path — the "Slack-only" constraint is intentional.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Ticket Schema — Extraction Notes
|
||||
|
||||
Field-by-field guidance for normalizing a Slack #bug-dump message into a ticket.
|
||||
|
||||
## `slack_ts`
|
||||
|
||||
The top-level message timestamp from `slack_read_channel` response (`Message TS:` field). Always store the dotted form (`1776510375.473579`). This is the ingestion identity used in `processed.json`.
|
||||
|
||||
## `slack_permalink`
|
||||
|
||||
Construct:
|
||||
|
||||
```text
|
||||
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p<ts-without-dot>
|
||||
```
|
||||
|
||||
Example: `1776510375.473579` → `.../p1776510375473579`.
|
||||
|
||||
## `reporter`
|
||||
|
||||
The display name + parenthetical nickname if present. Examples from the channel:
|
||||
|
||||
- `Ali Ranjah (wavey)`
|
||||
- `Denys Puziak`
|
||||
- `Christian Byrne`
|
||||
|
||||
Do NOT use the Slack user ID (`U087MJCDHHC`) in Linear — names are more readable.
|
||||
|
||||
## `title`
|
||||
|
||||
Rules:
|
||||
|
||||
- Start with a verb or noun phrase describing the observed defect, not the reporter.
|
||||
- ≤ 80 chars.
|
||||
- Include env qualifier ("cloud prod", "local dev", "electron") only if ambiguous.
|
||||
- Strip emoji and reactions from the original message when extracting.
|
||||
|
||||
Transformations:
|
||||
|
||||
| Slack message (excerpt) | Title |
|
||||
| ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| "unet model dropdown doesnt display all available models..." | Unet dropdown missing selected model |
|
||||
| "Dates are broken on Settings -> Secrets. Cloud Prod" | Settings → Secrets dates broken on cloud prod |
|
||||
| "LTX-2: Audio to VIdeo template results in the "RuntimeError..." error" | LTX-2 Audio-to-Video template RuntimeError on cloud |
|
||||
|
||||
## `description`
|
||||
|
||||
Structure — see `linear-api.md` § "Description body template". Key rules:
|
||||
|
||||
- Lead with **Repro** numbered list. Extract from the message body; if no steps are given, write "Repro: [Slack message body quoted verbatim]" and flag for human in approval.
|
||||
- Preserve the reporter's own words in the Repro section when they include "step 1 / step 2" markers.
|
||||
- Collapse multi-paragraph asides into "Notes" at the end.
|
||||
|
||||
## `env`
|
||||
|
||||
Detect from message text using these terms:
|
||||
|
||||
| Text in message | Tag |
|
||||
| -------------------------- | ---------------------- |
|
||||
| `cloud prod`, `prod cloud` | `cloud prod` |
|
||||
| `cloud dev` | `cloud dev` |
|
||||
| `cloud` | `cloud` (unqual.) |
|
||||
| `local`, `localhost` | `local` |
|
||||
| `electron`, `desktop` | `electron` |
|
||||
| `nodes 2.0`, `LG` | (feature tag, not env) |
|
||||
|
||||
A message can have multiple env tags. If none are detectable, set `env: []` and flag "env unclear" in the approval row.
|
||||
|
||||
## `severity`
|
||||
|
||||
Heuristics in SKILL.md. When uncertain, mark `medium` and note in approval table: `Sev: medium (flag)`.
|
||||
|
||||
## `area`
|
||||
|
||||
Single tag. Use the one that best fits; tiebreak toward the more actionable team:
|
||||
|
||||
- `cloud` > `workflow` when the reported behavior is specific to cloud-hosted features (billing, queue, jobs)
|
||||
- `node-system` > `ui` when the defect is canvas interaction, not just visual
|
||||
- `templates` only when a named template is the subject
|
||||
|
||||
## `attachments`
|
||||
|
||||
From `slack_read_channel` message `Files:` field. Parse name, ID, type. Never include the Slack file ID in the Linear description — those are permissioned — just the filename and type.
|
||||
|
||||
## `thread_resolution`
|
||||
|
||||
Fetch via `slack_read_thread`. Scan replies for:
|
||||
|
||||
- `solved`, `resolved`, `fixed`, `no action needed` → `solved`
|
||||
- A `:done:` reaction from the reporter → `solved`
|
||||
- A `https://github.com/Comfy-Org/ComfyUI_frontend/pull/` URL in a reply → `pr-open` (keep but note in description)
|
||||
- Otherwise → `open`
|
||||
|
||||
If `solved` and no PR merged, flag in approval table: reporter marked solved — confirm before filing.
|
||||
@@ -1,99 +0,0 @@
|
||||
# Verify Commands Cookbook
|
||||
|
||||
One-shot commands for each False-Defect Verification class. Keep each under ~30s.
|
||||
|
||||
## 1. Check for existing fix PR
|
||||
|
||||
```bash
|
||||
# By keyword in title
|
||||
gh search prs --repo Comfy-Org/ComfyUI_frontend "<keyword>" --state merged --limit 5
|
||||
|
||||
# By keyword in body
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --search "<keyword>" --state all --limit 5
|
||||
|
||||
# Recent closing PRs near the reported date
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
|
||||
--search "merged:>=<YYYY-MM-DD> <keyword>" --limit 10
|
||||
```
|
||||
|
||||
Verify tag: `fixed` if a merged PR explicitly matches; `pr-open` if an open PR matches.
|
||||
|
||||
## 2. Check for existing open Linear issue
|
||||
|
||||
```text
|
||||
# Primary: @Linear search in the candidate's bug-dump thread
|
||||
# mcp__plugin_slack_slack__slack_send_message({
|
||||
# channel_id: "C0A4XMHANP3",
|
||||
# thread_ts: "<parent-ts>",
|
||||
# text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
# })
|
||||
# → poll slack_read_thread, parse the Linear app's reply card for FE-NNNN matches.
|
||||
#
|
||||
# Fallback: grep past @Linear bot replies in the channel for prior ingested titles
|
||||
# mcp__plugin_slack_slack__slack_search_public({
|
||||
# query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
|
||||
# })
|
||||
```
|
||||
|
||||
Verify tag: `dedupe` with the `FE-NNNN` identifier in the approval row. See `reference/linear-api.md` § "Search existing open issues (dedupe)" for full handling.
|
||||
|
||||
## 3. Feature actually exists in codebase
|
||||
|
||||
```bash
|
||||
# Find the component / feature mentioned
|
||||
rg -l "<ComponentOrFeatureName>" src/ apps/ --type vue --type ts
|
||||
|
||||
# Find a setting key
|
||||
rg "<setting-key>" src/locales/en/ src/stores/settingStore.ts
|
||||
|
||||
# Find a store action
|
||||
rg "<actionName>" src/stores/ --type ts
|
||||
```
|
||||
|
||||
Verify tag: `stale` if 0 hits AND the feature name is specific (not a generic word).
|
||||
|
||||
## 4. Intended behavior check
|
||||
|
||||
```bash
|
||||
# Check docs and release notes
|
||||
rg -l "<feature keyword>" docs/ CHANGELOG.md
|
||||
|
||||
# Check if behavior is asserted in an existing test (green today)
|
||||
rg "<observed behavior>" src/**/*.test.ts browser_tests/
|
||||
```
|
||||
|
||||
Verify tag: `expected` if docs describe this as the intended behavior, or a test asserts it.
|
||||
|
||||
## 5. Reporter self-resolution
|
||||
|
||||
Already gathered via `slack_read_thread`. Look for reporter's own replies containing:
|
||||
|
||||
- "solved", "resolved", "fixed", "no action needed", "nvm", "my bad"
|
||||
- A `:done:` reaction from the reporter
|
||||
- A `:white_check_mark:` reaction
|
||||
|
||||
Verify tag: `resolved`.
|
||||
|
||||
## 6. Env-specific / local setup
|
||||
|
||||
If the message mentions "my machine", "my proxy", "my docker", "my cache" AND no other reporter has confirmed in-thread:
|
||||
|
||||
```bash
|
||||
# Check thread for cross-user confirmations
|
||||
# slack_read_thread → count distinct users replying with "same", "repro'd", "+1"
|
||||
```
|
||||
|
||||
Verify tag: `env` if only the reporter is affected.
|
||||
|
||||
## 7. Cross-post (X posting)
|
||||
|
||||
If the top-level message is just a link + "X posting":
|
||||
|
||||
```bash
|
||||
# Follow the link — use slack_search_public to find the original thread
|
||||
# slack_search_public({ query: "<in:channel from:@reporter> <before:date>" })
|
||||
```
|
||||
|
||||
If the original is already ingestable, ingest from the original's permalink. If it's a GitHub issue, prefer linking that GitHub issue to the Linear ticket instead of creating two entries.
|
||||
|
||||
Verify tag: `cross-post` with the resolved source permalink.
|
||||
@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
|
||||
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
|
||||
@@ -46,9 +46,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
|
||||
|
||||
# Ashby (apps/website careers page build).
|
||||
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
|
||||
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
|
||||
# WEBSITE_ASHBY_API_KEY=
|
||||
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Upsert Comment Section
|
||||
description: >
|
||||
Manage a consolidated PR comment with independently-updatable sections.
|
||||
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
|
||||
Valid section names: "e2e", "preview", "screenshot-update".
|
||||
|
||||
inputs:
|
||||
pr-number:
|
||||
description: PR number to comment on
|
||||
required: true
|
||||
section-name:
|
||||
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
|
||||
required: true
|
||||
section-content:
|
||||
description: Markdown content for this section
|
||||
required: true
|
||||
comment-marker:
|
||||
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
|
||||
required: true
|
||||
token:
|
||||
description: GitHub token with pull-requests write permission
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
|
||||
INPUT_SECTION_NAME: ${{ inputs.section-name }}
|
||||
INPUT_SECTION_CONTENT: ${{ inputs.section-content }}
|
||||
INPUT_COMMENT_MARKER: ${{ inputs.comment-marker }}
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
const prNumber = Number(process.env.INPUT_PR_NUMBER)
|
||||
const sectionName = process.env.INPUT_SECTION_NAME
|
||||
const sectionContent = process.env.INPUT_SECTION_CONTENT
|
||||
const commentMarker = process.env.INPUT_COMMENT_MARKER
|
||||
|
||||
const sectionStart = `<!-- section:${sectionName}:start -->`
|
||||
const sectionEnd = `<!-- section:${sectionName}:end -->`
|
||||
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
|
||||
|
||||
// Escape special regex characters in delimiter strings
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ ...context.repo, issue_number: prNumber }
|
||||
)
|
||||
|
||||
const existing = comments.find(
|
||||
(c) =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(commentMarker)
|
||||
)
|
||||
|
||||
if (!existing) {
|
||||
return github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: prNumber,
|
||||
body: `${commentMarker}\n${sectionBlock}`
|
||||
})
|
||||
}
|
||||
|
||||
const body = existing.body ?? ''
|
||||
const sectionRegex = new RegExp(
|
||||
`${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
|
||||
)
|
||||
const updated = sectionRegex.test(body)
|
||||
? body.replace(sectionRegex, sectionBlock)
|
||||
: body.trimEnd() + '\n\n' + sectionBlock
|
||||
|
||||
return github.rest.issues.updateComment({
|
||||
...context.repo,
|
||||
comment_id: existing.id,
|
||||
body: updated
|
||||
})
|
||||
2
.github/workflows/ci-perf-report.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
|
||||
11
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -51,16 +51,15 @@ jobs:
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: playwright-report-.*
|
||||
name_is_regexp: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
74
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -15,36 +16,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Detect whether e2e-relevant files changed. Required checks see "skipped"
|
||||
# (which counts as passing) when only docs/apps/storybook files are touched,
|
||||
# avoiding the stall that paths-ignore would cause.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/checkout@v6
|
||||
- name: Check for e2e-relevant changes
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
predicate-quantifier: 'every'
|
||||
filters: |
|
||||
e2e:
|
||||
- '**'
|
||||
- '!apps/**'
|
||||
- '!docs/**'
|
||||
- '!.storybook/**'
|
||||
- '!**/*.md'
|
||||
|
||||
setup:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should_run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -82,7 +54,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -140,7 +112,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -192,9 +164,9 @@ jobs:
|
||||
|
||||
# Merge sharded test reports (no container needed - only runs CLI)
|
||||
merge-reports:
|
||||
needs: [changes, playwright-tests-chromium-sharded]
|
||||
needs: [playwright-tests-chromium-sharded]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
@@ -223,38 +195,14 @@ jobs:
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
# Gate job — single required check that passes whether the matrix ran or was
|
||||
# skipped. Branch rulesets require this instead of the individual matrix-
|
||||
# expanded check names so PRs with no e2e-relevant changes aren't stuck.
|
||||
e2e-status:
|
||||
if: ${{ always() }}
|
||||
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check E2E results
|
||||
env:
|
||||
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
|
||||
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
|
||||
BROWSERS: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
|
||||
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
|
||||
echo "E2E passed"
|
||||
|
||||
#### BEGIN Deployment and commenting (non-forked PRs only)
|
||||
# when using pull_request event, we have permission to comment directly
|
||||
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
|
||||
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -273,15 +221,9 @@ jobs:
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [changes, playwright-tests, merge-reports]
|
||||
needs: [playwright-tests, merge-reports]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
needs.changes.outputs.should_run == 'true' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
}}
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
157
.github/workflows/ci-website-e2e.yaml
vendored
@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -27,17 +27,35 @@ jobs:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
outputs:
|
||||
test-outcome: ${{ steps.tests.outcome }}
|
||||
report-url: ${{ steps.deploy.outputs.url }}
|
||||
screenshot-failures: ${{ steps.failures.outputs.screenshot }}
|
||||
other-failures: ${{ steps.failures.outputs.other }}
|
||||
# Evaluated at job level (not from a step) — static expression.
|
||||
is-pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- WEBSITE_E2E_STATUS -->'
|
||||
const body = [
|
||||
marker,
|
||||
'## 🌐 Website E2E',
|
||||
'',
|
||||
'> [!NOTE]',
|
||||
`> Tests are running… [View workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
|
||||
].join('\n')
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number
|
||||
})
|
||||
const existing = comments.find(c => c.body.includes(marker))
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
|
||||
} else {
|
||||
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
|
||||
}
|
||||
|
||||
- name: Install pnpm
|
||||
run: corepack enable && corepack prepare
|
||||
|
||||
@@ -116,20 +134,15 @@ jobs:
|
||||
core.setOutput('screenshot', screenshotFailures)
|
||||
core.setOutput('other', failed.length - screenshotFailures)
|
||||
|
||||
- name: Write job summary
|
||||
- name: Post result comment and summary
|
||||
if: always() && !cancelled()
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TEST_OUTCOME: ${{ steps.tests.outcome }}
|
||||
REPORT_URL: ${{ steps.deploy.outputs.url }}
|
||||
SCREENSHOT_FAILURES: ${{ steps.failures.outputs.screenshot }}
|
||||
OTHER_FAILURES: ${{ steps.failures.outputs.other }}
|
||||
with:
|
||||
script: |
|
||||
const passed = process.env.TEST_OUTCOME === 'success'
|
||||
const reportUrl = process.env.REPORT_URL
|
||||
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
|
||||
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
|
||||
const passed = '${{ steps.tests.outcome }}' === 'success'
|
||||
const reportUrl = '${{ steps.deploy.outputs.url }}'
|
||||
const screenshotFailures = parseInt('${{ steps.failures.outputs.screenshot }}') || 0
|
||||
const otherFailures = parseInt('${{ steps.failures.outputs.other }}') || 0
|
||||
|
||||
const lines = ['## 🌐 Website E2E', '']
|
||||
|
||||
@@ -139,87 +152,7 @@ jobs:
|
||||
lines.push('> [!CAUTION]', '> Some tests failed.')
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['Status', passed ? '✅ Passed' : '❌ Failed'],
|
||||
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
|
||||
]
|
||||
if (!passed) {
|
||||
rows.push(
|
||||
['Screenshot diffs', String(screenshotFailures)],
|
||||
['Other failures', String(otherFailures)]
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'| | |',
|
||||
'|---|---|',
|
||||
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
|
||||
)
|
||||
|
||||
await core.summary.addRaw(lines.join('\n')).write()
|
||||
|
||||
post-starting-comment:
|
||||
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
|
||||
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
concurrency:
|
||||
group: website-pr-comment-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: e2e
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 🌐 Website E2E
|
||||
<!-- WEBSITE_E2E_STATUS -->
|
||||
|
||||
> [!NOTE]
|
||||
> Tests are running… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
|
||||
post-result-comment:
|
||||
needs: website-e2e
|
||||
if: always() && !cancelled() && needs.website-e2e.outputs.is-pr == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
concurrency:
|
||||
group: website-pr-comment-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build e2e section content
|
||||
id: content
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TEST_OUTCOME: ${{ needs.website-e2e.outputs.test-outcome }}
|
||||
REPORT_URL: ${{ needs.website-e2e.outputs.report-url }}
|
||||
SCREENSHOT_FAILURES: ${{ needs.website-e2e.outputs.screenshot-failures }}
|
||||
OTHER_FAILURES: ${{ needs.website-e2e.outputs.other-failures }}
|
||||
with:
|
||||
script: |
|
||||
const passed = process.env.TEST_OUTCOME === 'success'
|
||||
const reportUrl = process.env.REPORT_URL
|
||||
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
|
||||
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
|
||||
|
||||
const lines = ['## 🌐 Website E2E', '<!-- WEBSITE_E2E_STATUS -->', '']
|
||||
|
||||
if (passed) {
|
||||
lines.push('> [!TIP]', '> All tests passed.')
|
||||
} else {
|
||||
lines.push('> [!CAUTION]', '> Some tests failed.')
|
||||
}
|
||||
|
||||
// Results table
|
||||
const rows = [
|
||||
['Status', passed ? '✅ Passed' : '❌ Failed'],
|
||||
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
|
||||
@@ -249,12 +182,20 @@ jobs:
|
||||
)
|
||||
}
|
||||
|
||||
core.setOutput('section-content', lines.join('\n'))
|
||||
const summary = lines.join('\n')
|
||||
await core.summary.addRaw(summary).write()
|
||||
|
||||
- uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: e2e
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: ${{ steps.content.outputs.section-content }}
|
||||
if (context.eventName === 'pull_request' && !context.payload.pull_request.head.repo.fork) {
|
||||
const marker = '<!-- WEBSITE_E2E_STATUS -->'
|
||||
const body = `${marker}\n${summary}`
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number
|
||||
})
|
||||
const existing = comments.find(c => c.body.includes(marker))
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
|
||||
} else {
|
||||
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
122
.github/workflows/pr-update-website-screenshots.yaml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-screenshots:
|
||||
@@ -16,11 +16,7 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
|
||||
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.label.name == 'Update Website Screenshots' ) ||
|
||||
@@ -38,11 +34,6 @@ jobs:
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
|
||||
contains(github.event.comment.body, '- [x] Update website screenshots') )
|
||||
outputs:
|
||||
pr-number: ${{ steps.pr-info.outputs.pr-number }}
|
||||
update-outcome: ${{ steps.update-screenshots.outcome }}
|
||||
has-changes: ${{ steps.commit.outputs.has-changes }}
|
||||
changed-count: ${{ steps.commit.outputs.changed-count }}
|
||||
steps:
|
||||
- name: Verify sender permissions
|
||||
if: >
|
||||
@@ -62,11 +53,9 @@ jobs:
|
||||
- name: Get PR info
|
||||
id: pr-info
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number || github.event.issue.number }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number(process.env.PR_NUMBER)
|
||||
const prNumber = ${{ github.event.number || github.event.issue.number }}
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: prNumber
|
||||
@@ -101,10 +90,7 @@ jobs:
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
|
||||
CHANGED=$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/ | wc -l)
|
||||
echo "changed-count=${CHANGED}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$CHANGED" -eq 0 ]; then
|
||||
if [ -z "$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/)" ]; then
|
||||
echo "No screenshot changes to commit"
|
||||
echo "has-changes=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
@@ -126,114 +112,14 @@ jobs:
|
||||
- name: Remove label
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
issue_number: ${{ steps.pr-info.outputs.pr-number }},
|
||||
name: 'Update Website Screenshots'
|
||||
})
|
||||
} catch (e) {
|
||||
// Label may already be removed
|
||||
}
|
||||
|
||||
post-starting-comment:
|
||||
# Runs in parallel with update-screenshots to show "in progress" immediately.
|
||||
# ⚠️ This condition is duplicated from `update-screenshots` — keep them in sync.
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.label.name == 'Update Website Screenshots' ) ||
|
||||
( github.event.issue.pull_request &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'
|
||||
) &&
|
||||
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
|
||||
( github.event.issue.pull_request &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.comment.user.login == 'github-actions[bot]' &&
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
|
||||
contains(github.event.comment.body, '- [x] Update website screenshots') )
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
concurrency:
|
||||
group: website-pr-comment-${{ github.event.number || github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.number || github.event.issue.number }}
|
||||
section-name: screenshot-update
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 📸 Screenshot Update
|
||||
|
||||
> [!NOTE]
|
||||
> Updating screenshots… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
|
||||
post-result-comment:
|
||||
needs: update-screenshots
|
||||
if: always() && !cancelled() && needs.update-screenshots.result != 'skipped'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
concurrency:
|
||||
group: website-pr-comment-${{ needs.update-screenshots.outputs.pr-number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build screenshot-update section content
|
||||
id: content
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
UPDATE_OUTCOME: ${{ needs.update-screenshots.outputs.update-outcome }}
|
||||
HAS_CHANGES: ${{ needs.update-screenshots.outputs.has-changes }}
|
||||
CHANGED_COUNT: ${{ needs.update-screenshots.outputs.changed-count }}
|
||||
with:
|
||||
script: |
|
||||
const outcome = process.env.UPDATE_OUTCOME
|
||||
const hasChanges = process.env.HAS_CHANGES === 'true'
|
||||
const changedCount = parseInt(process.env.CHANGED_COUNT) || 0
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
|
||||
|
||||
const lines = ['## 📸 Screenshot Update', '']
|
||||
|
||||
if (outcome !== 'success') {
|
||||
lines.push(
|
||||
'> [!CAUTION]',
|
||||
`> Screenshot update failed. [View workflow run](${runUrl})`
|
||||
)
|
||||
} else if (!hasChanges) {
|
||||
lines.push(
|
||||
'> [!TIP]',
|
||||
'> All screenshots are already up to date.'
|
||||
)
|
||||
} else {
|
||||
const s = changedCount === 1 ? '' : 's'
|
||||
lines.push(
|
||||
'> [!TIP]',
|
||||
`> Updated ${changedCount} screenshot${s} and pushed to the branch.`
|
||||
)
|
||||
}
|
||||
|
||||
core.setOutput('section-content', lines.join('\n'))
|
||||
|
||||
- uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
|
||||
section-name: screenshot-update
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: ${{ steps.content.outputs.section-content }}
|
||||
|
||||
50
.github/workflows/pr-vercel-website-preview.yaml
vendored
@@ -7,6 +7,11 @@ on:
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
@@ -14,16 +19,6 @@ concurrency:
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
# Uses head_branch as proxy for PR number (unavailable at job-level in workflow_run).
|
||||
# Preview and E2E comment writes are NOT mutually serialized — the race window is
|
||||
# small and self-healing on next push.
|
||||
concurrency:
|
||||
group: website-pr-comment-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
@@ -42,29 +37,28 @@ jobs:
|
||||
id: pr-meta
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Read preview URLs
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: urls
|
||||
run: |
|
||||
echo "stable-url=$(cat temp/vercel-preview/stable-url.txt)" >> "$GITHUB_OUTPUT"
|
||||
echo "unique-url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
|
||||
echo "short-sha=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
|
||||
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
|
||||
SHORT_SHA="${HEAD_SHA:0:7}"
|
||||
cat > preview-report.md <<EOF
|
||||
**Website Preview:** $STABLE_URL
|
||||
|
||||
- name: Post preview comment
|
||||
<sub>This commit: $UNIQUE_URL</sub>
|
||||
|
||||
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
|
||||
EOF
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
section-name: preview
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
report-file: ./preview-report.md
|
||||
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 🔗 Website Preview
|
||||
|
||||
**Website Preview:** ${{ steps.urls.outputs.stable-url }}
|
||||
|
||||
<sub>This commit: ${{ steps.urls.outputs.unique-url }}</sub>
|
||||
|
||||
<sub>Last updated: ${{ github.event.workflow_run.updated_at }} for `${{ steps.urls.outputs.short-sha }}`</sub>
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-700);
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-600);
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-500);
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
|
||||
1
apps/website/.gitignore
vendored
@@ -2,7 +2,6 @@ dist/
|
||||
.astro/
|
||||
test-results/
|
||||
playwright-report/
|
||||
results.json
|
||||
|
||||
# Platform-specific Playwright snapshots (CI runs Linux)
|
||||
*-win32.png
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# @comfyorg/website
|
||||
|
||||
Marketing/brand website built with Astro + Vue.
|
||||
|
||||
## Ashby careers integration
|
||||
|
||||
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
|
||||
API at build time. Data flow:
|
||||
|
||||
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
|
||||
Astro build.
|
||||
2. `src/utils/ashby.ts` calls
|
||||
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
|
||||
validates the envelope and each posting with Zod
|
||||
(`src/utils/ashby.schema.ts`), and maps to the domain type in
|
||||
`src/data/roles.ts`.
|
||||
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
|
||||
the fetcher falls back to the committed JSON snapshot at
|
||||
`src/data/ashby-roles.snapshot.json`.
|
||||
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
|
||||
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
|
||||
builds.
|
||||
|
||||
### Required environment variables
|
||||
|
||||
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
|
||||
inline that into the client bundle).
|
||||
|
||||
| Name | Purpose | Default (when unset) |
|
||||
| ------------------------------ | --------------------------- | --------------------------------- |
|
||||
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
|
||||
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
|
||||
|
||||
### CI wiring (manual step — required)
|
||||
|
||||
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
|
||||
GitHub App. A maintainer must apply the following edits **once**:
|
||||
|
||||
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
|
||||
build step and run the unit tests before it:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run website unit tests
|
||||
run: pnpm --filter @comfyorg/website test:unit
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Verify API key is not leaked into build output
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
run: |
|
||||
set +x
|
||||
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
|
||||
echo "Secret not available in this run; skipping leak check."
|
||||
exit 0
|
||||
fi
|
||||
# grep -rlF prints only file paths (never match content).
|
||||
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
|
||||
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
|
||||
| tr '\0' '\n' || true)
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "::error title=Ashby API key leaked into build output::$MATCHES"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
|
||||
two env vars to the top-level `env:` block so `vercel build` (both
|
||||
`deploy-preview` and `deploy-production` jobs) sees them:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
VERCEL_SCOPE: comfyui
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
|
||||
```
|
||||
|
||||
The secret must also be added to the Vercel project environment
|
||||
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
|
||||
that `vercel build` in the preview job has access to it.
|
||||
|
||||
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
|
||||
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
|
||||
before the build runs. Fork-safe PR interactions (the preview-URL
|
||||
comment) are handled by `pr-vercel-website-preview.yaml`.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
When a maintainer wants to update the committed snapshot (e.g. after
|
||||
onboarding/offboarding roles):
|
||||
|
||||
```bash
|
||||
WEBSITE_ASHBY_API_KEY=… WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
|
||||
pnpm --filter @comfyorg/website ashby:refresh-snapshot
|
||||
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.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` — Astro dev server
|
||||
- `pnpm build` — production build to `dist/`
|
||||
- `pnpm typecheck` — `astro check`
|
||||
- `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
|
||||
@@ -6,16 +6,6 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
redirects: {
|
||||
'/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/'
|
||||
},
|
||||
build: {
|
||||
assets: '_website'
|
||||
},
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [vue(), sitemap()],
|
||||
vite: {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Careers page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/careers')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Careers — Comfy')
|
||||
})
|
||||
|
||||
test('Roles section heading is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Roles', level: 2 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders at least one role from the snapshot', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
await expect(roles.first()).toBeVisible()
|
||||
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('ENGINEERING category filter narrows the role list', async ({
|
||||
page
|
||||
}) => {
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
await expect(page).toHaveTitle('招聘 — Comfy')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '职位', level: 2 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -122,6 +122,17 @@ test.describe('About', { tag: '@visual' }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
const OVERFLOW_SKIP = new Set([
|
||||
'/ 3-lg',
|
||||
'/ 4-xl',
|
||||
'/cloud 2-md',
|
||||
'/cloud 3-lg',
|
||||
'/cloud 4-xl',
|
||||
'/download 2-md',
|
||||
'/download 3-lg',
|
||||
'/download 4-xl'
|
||||
])
|
||||
|
||||
test.describe('Overflow guards', { tag: '@visual' }, () => {
|
||||
const pages = [
|
||||
'/',
|
||||
@@ -135,7 +146,10 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
|
||||
]
|
||||
for (const url of pages) {
|
||||
for (const vp of VIEWPORTS) {
|
||||
const key = `${url} ${vp.name}`
|
||||
|
||||
test(`${url} ${vp.name} no overflow`, async ({ page }) => {
|
||||
test.skip(OVERFLOW_SKIP.has(key), 'Known overflow bug at this viewport')
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height })
|
||||
await page.goto(url)
|
||||
await assertNoOverflow(page)
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 103 KiB |
@@ -9,13 +9,10 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "astro check",
|
||||
"test:unit": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"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"
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
@@ -26,8 +23,7 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
@@ -36,9 +32,7 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
@@ -95,22 +89,6 @@
|
||||
"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": [
|
||||
|
||||
19
apps/website/public/svg/404-title.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="title404" fill="currentColor" transform="matrix(0.9346 -0.02737 -0.00901 1.0422 -597.31 -135.88)">
|
||||
<path d="M1436.44 522.556V439.749L1462.05 454.536C1478.8 464.21 1489.92 479.348 1489.92 505.313V518.746C1489.92 544.611 1478.72 546.966 1462.05 537.342L1436.44 522.556ZM1442.77 519.899L1461.87 530.926C1477.15 539.748 1483.32 535.087 1483.32 515.137V501.303C1483.32 481.253 1477.15 469.573 1461.87 460.751L1442.77 449.724V519.899Z"/>
|
||||
<path d="M1369.77 484.067V401.261L1376.02 404.87L1416.65 498.303V428.328L1422.73 431.837V514.643L1416.48 511.034L1375.85 417.601V487.576L1369.77 484.067Z"/>
|
||||
<path d="M1330.66 462.491C1311.56 451.464 1304.62 438.231 1304.62 417.68V363.645L1311.04 367.354V421.69C1311.04 440.737 1318.34 448.857 1330.66 455.975C1342.99 463.093 1350.2 463.343 1350.2 444.296V389.96L1356.62 393.67V447.704C1356.62 468.757 1349.24 473.218 1330.66 462.491Z"/>
|
||||
<path d="M1265.37 424.794C1245.75 413.466 1236.11 392.263 1236.11 372.614V358.278C1236.11 338.83 1245.75 328.654 1265.37 339.983C1285.08 351.361 1294.72 372.664 1294.72 392.113V406.448C1294.72 426.097 1285.08 436.173 1265.37 424.794ZM1242.71 376.724C1242.71 394.068 1250.53 409.506 1265.37 418.077C1280.3 426.699 1288.12 420.283 1288.12 402.94V388.002C1288.12 370.96 1280.3 355.321 1265.37 346.699C1250.53 338.128 1242.71 344.745 1242.71 361.787V376.724Z"/>
|
||||
<path d="M1187.24 378.685V295.879L1231.78 321.593V328.009L1193.58 305.954V341.643L1229.79 362.545V368.961L1193.58 348.059V382.344L1187.24 378.685Z"/>
|
||||
<path d="M1129.17 345.153V268.763L1106.33 255.58V249.164L1158.34 279.189V285.605L1135.5 272.422V348.812L1129.17 345.153Z"/>
|
||||
<path d="M1076.63 315.825C1057.01 304.497 1047.37 283.294 1047.37 263.645V249.309C1047.37 229.861 1057.01 219.685 1076.63 231.014C1096.34 242.392 1105.98 263.695 1105.98 283.144V297.479C1105.98 317.128 1096.34 327.204 1076.63 315.825ZM1053.97 267.755C1053.97 285.099 1061.79 300.537 1076.63 309.108C1091.56 317.73 1099.38 311.314 1099.38 293.971V279.033C1099.38 261.991 1091.56 246.352 1076.63 237.73C1061.79 229.159 1053.97 235.776 1053.97 252.818V267.755Z"/>
|
||||
<path d="M983.922 261.297V178.49L990.173 182.099L1030.8 275.532V205.558L1036.88 209.066V291.873L1030.63 288.264L989.999 194.831V264.805L983.922 261.297Z"/>
|
||||
<path d="M905.279 215.892V133.086L949.557 158.65V165.066L911.617 143.161V174.94L947.994 195.943V202.359L911.617 181.356V213.135L949.557 235.04V241.456L905.279 215.892Z"/>
|
||||
<path d="M864.552 193.381C846.059 182.704 836.422 161.702 836.422 141.852V127.517C836.422 107.767 845.972 97.8425 865.507 109.121C883.131 119.296 892.855 137.642 893.723 154.083L893.81 158.243L887.038 154.333V149.822C886.343 135.687 878.356 123.156 865.68 115.837C850.313 106.965 842.934 113.832 842.934 130.875V145.912C842.934 163.356 850.4 178.594 865.073 187.065C878.096 194.584 886.864 191.225 887.125 175.436V170.123L860.732 154.885V148.669L893.81 167.767V209.27L889.208 206.614L888.601 191.426C884.52 198.193 876.706 200.398 864.552 193.381Z"/>
|
||||
<path d="M777.479 142.107L801.615 73.2351L809.515 77.7965L833.738 174.588L826.966 170.678L821.93 150.027L789.286 131.18L784.251 146.017L777.479 142.107ZM791.11 125.816L820.107 142.558L805.869 84.2124L805.522 83.0094L805.261 83.8616L791.11 125.816Z"/>
|
||||
<path d="M727.161 113.056V30.2494L756.853 47.3921C772.047 56.164 779.513 69.2967 779.513 86.9407V88.1437C779.513 105.687 772.047 110.6 756.853 101.828L733.499 88.3442V116.715L727.161 113.056ZM733.499 82.0285L756.419 95.2615C767.792 101.828 772.915 98.67 772.915 84.7352V82.63C772.915 68.7955 767.792 60.0236 756.419 53.4572L733.499 40.2242V82.0285Z"/>
|
||||
<path d="M582.85 5.5V67.98L592.376 62.48V72.38L582.85 77.88V96.36L572.942 102.08V83.6L532.075 107.195V96.855L573.133 11.11L582.85 5.5ZM541.601 91.795L572.942 73.7V27.61L541.601 91.795Z"/>
|
||||
<path d="M495.102 148.121C475.096 159.671 464.903 150.816 464.903 126.286V111.546C464.903 87.2364 475.192 66.5564 495.102 55.0614C514.631 43.7864 524.824 52.6414 524.824 76.9514V91.6914C524.824 116.221 514.631 136.846 495.102 148.121ZM475.001 121.556C475.001 127.936 475.763 132.666 477.287 136.076L509.296 63.2564C506.057 60.8364 501.294 61.3864 495.102 64.9614C481.765 72.6614 475.001 86.0264 475.001 104.726V121.556ZM480.812 140.311C484.146 142.456 488.91 141.796 495.102 138.221C508.057 130.741 514.726 117.431 514.726 98.5114V81.6814C514.726 75.7414 514.059 71.1764 512.725 67.8764L480.812 140.311Z"/>
|
||||
<path d="M447.443 83.6773V146.157L456.969 140.657V150.557L447.443 156.057V174.537L437.535 180.257V161.777L396.668 185.372V175.032L437.726 89.2873L447.443 83.6773ZM406.194 169.972L437.535 151.877V105.787L406.194 169.972Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1,33 +0,0 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchRolesForBuild } from '../src/utils/ashby'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify(outcome.snapshot, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
renameSync(tempPath, snapshotPath)
|
||||
const totalRoles = outcome.snapshot.departments.reduce(
|
||||
(n, d) => n + d.roles.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const investors = [
|
||||
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
|
||||
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
|
||||
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
|
||||
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
|
||||
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
|
||||
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.story.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('about.story.headingBefore', locale)
|
||||
}}<span class="text-primary-comfy-yellow">{{
|
||||
t('about.story.headingHighlight', locale)
|
||||
}}</span
|
||||
>{{ t('about.story.headingAfter', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
|
||||
{{ t('about.story.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Investor card -->
|
||||
<div
|
||||
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
|
||||
>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- OUR badge (shorter) -->
|
||||
<div class="relative z-10 flex h-9 items-center">
|
||||
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
|
||||
>
|
||||
OUR
|
||||
</span>
|
||||
</div>
|
||||
<!-- Union connector (overlaps both badges to eliminate seams) -->
|
||||
<img
|
||||
src="/icons/node-union-2size-reverse.svg"
|
||||
alt=""
|
||||
class="relative z-20 -mx-px h-12 w-auto"
|
||||
/>
|
||||
<!-- INVESTORS badge (taller) -->
|
||||
<div class="relative z-10 flex h-12 items-center">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
|
||||
>
|
||||
INVESTORS
|
||||
</span>
|
||||
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('about.story.investorsBody', locale) }}
|
||||
</p>
|
||||
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
|
||||
<div
|
||||
v-for="investor in investors"
|
||||
:key="investor.name"
|
||||
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
|
||||
>
|
||||
<img
|
||||
:src="investor.icon"
|
||||
:alt="investor.name"
|
||||
class="max-h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
|
||||
>
|
||||
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
|
||||
{{ t('about.quote.text', locale) }}
|
||||
</p>
|
||||
<p
|
||||
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
|
||||
>
|
||||
{{ t('about.quote.attribution', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,42 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
const { locale = 'en', departments = [] } = defineProps<{
|
||||
locale?: Locale
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
interface Role {
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
id: string
|
||||
}
|
||||
|
||||
interface Department {
|
||||
name: string
|
||||
key: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
const departments: Department[] = [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: 'abc787b9-ad85-421c-8218-debd23bea096'
|
||||
},
|
||||
{
|
||||
title: 'Software Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
|
||||
},
|
||||
{
|
||||
title: 'Product Manager',
|
||||
department: 'Engineering',
|
||||
location: 'London, UK',
|
||||
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
|
||||
},
|
||||
{
|
||||
title: 'Tech Lead Manager, Frontend',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'DESIGN',
|
||||
key: 'design',
|
||||
roles: [
|
||||
{
|
||||
title: 'Creative Director',
|
||||
department: 'Design',
|
||||
location: 'San Francisco',
|
||||
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
|
||||
},
|
||||
{
|
||||
title: 'Graphic Designer',
|
||||
department: 'Design',
|
||||
location: 'London, UK',
|
||||
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
|
||||
},
|
||||
{
|
||||
title: 'Freelance Motion Designer',
|
||||
department: 'Design',
|
||||
location: 'Remote',
|
||||
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'MARKETING',
|
||||
key: 'marketing',
|
||||
roles: [
|
||||
{
|
||||
title: 'Lifecycle Growth Marketer',
|
||||
department: 'Marketing',
|
||||
location: 'San Francisco',
|
||||
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
|
||||
},
|
||||
{
|
||||
title: 'Graphic Designer',
|
||||
department: 'Marketing',
|
||||
location: 'London, UK',
|
||||
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
...departments.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
? departments
|
||||
: departments.filter((d) => d.key === activeCategory.value)
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
|
||||
<section class="px-6 py-20 md:px-20 md:py-32">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<!-- Left sidebar -->
|
||||
<div class="shrink-0 md:w-48">
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
|
||||
@@ -47,7 +126,6 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
{{ t('careers.roles.heading', locale) }}
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
class="mt-4"
|
||||
@@ -55,15 +133,8 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role listings -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
v-if="!hasRoles"
|
||||
class="text-primary-warm-gray text-base md:text-lg"
|
||||
data-testid="careers-roles-empty"
|
||||
>
|
||||
{{ t('careers.roles.empty', locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in filteredDepartments"
|
||||
:key="dept.key"
|
||||
@@ -76,11 +147,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.applyUrl"
|
||||
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
data-testid="careers-role-link"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
|
||||
@@ -5,6 +5,8 @@ const { playing = false, size = 'md' } = defineProps<{
|
||||
playing?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,6 +18,7 @@ const { playing = false, size = 'md' } = defineProps<{
|
||||
size === 'sm' ? 'size-8 lg:size-10' : 'size-12'
|
||||
)
|
||||
"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<svg
|
||||
v-if="playing"
|
||||
|
||||
@@ -204,12 +204,13 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<GitHubStarBadge v-if="githubStars" :stars="githubStars" />
|
||||
<BrandButton
|
||||
v-for="cta in ctaButtons"
|
||||
:key="cta.href"
|
||||
|
||||
@@ -201,7 +201,6 @@ function toggleFullscreen() {
|
||||
playsinline
|
||||
:autoplay
|
||||
muted
|
||||
@click="playing = !playing"
|
||||
>
|
||||
<track
|
||||
v-for="track in tracks"
|
||||
@@ -222,14 +221,13 @@ function toggleFullscreen() {
|
||||
playing && !hovering && 'pointer-events-none opacity-0'
|
||||
)
|
||||
"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
<PlayPauseButton
|
||||
:playing
|
||||
:aria-label="
|
||||
playing ? t('player.pause', locale) : t('player.play', locale)
|
||||
"
|
||||
@click.stop="playing = !playing"
|
||||
@toggle="playing = !playing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -250,7 +248,7 @@ function toggleFullscreen() {
|
||||
:aria-label="
|
||||
playing ? t('player.pause', locale) : t('player.play', locale)
|
||||
"
|
||||
@click="playing = !playing"
|
||||
@toggle="playing = !playing"
|
||||
/>
|
||||
|
||||
<!-- Progress scrubber -->
|
||||
|
||||
@@ -1,22 +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="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-10 lg:pb-40"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('customers.contact.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-4 max-w-2xl text-3xl font-light whitespace-pre-line"
|
||||
v-html="t('customers.contact.heading', locale)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
@@ -15,8 +16,12 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('gallery.contact.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-4 max-w-2xl text-2xl font-light whitespace-pre-line"
|
||||
v-html="t('gallery.contact.heading', locale)"
|
||||
/>
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t('gallery.contact.heading', locale) }}
|
||||
</h2>
|
||||
<BrandButton href="/contact" variant="outline-dark" size="sm" class="mt-8">
|
||||
{{ t('gallery.contact.cta', locale) }}
|
||||
</BrandButton>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { src, clipId, objectPosition } = defineProps<{
|
||||
src: string
|
||||
clipId: string
|
||||
objectPosition?: 'top' | 'bottom' | 'center'
|
||||
}>()
|
||||
|
||||
const positionClass = {
|
||||
top: 'object-top',
|
||||
bottom: 'object-bottom',
|
||||
center: 'object-center'
|
||||
} as const
|
||||
|
||||
function isVideo(url: string): boolean {
|
||||
return url.endsWith('.webm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full overflow-hidden"
|
||||
:style="`clip-path: url(#${clipId})`"
|
||||
>
|
||||
<Transition name="crossfade">
|
||||
<video
|
||||
v-if="isVideo(src)"
|
||||
:key="`video-${src}`"
|
||||
:src
|
||||
:poster="src.replace('.webm', '.webp')"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full object-cover',
|
||||
objectPosition && positionClass[objectPosition]
|
||||
)
|
||||
"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:key="`img-${src}`"
|
||||
:src
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full object-cover',
|
||||
objectPosition && positionClass[objectPosition]
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { side } = defineProps<{
|
||||
side: 'left' | 'right'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none flex min-h-0 items-center overflow-clip lg:overflow-visible"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'aspect-2/3 w-40 lg:aspect-auto lg:h-[72vh] lg:max-h-240 lg:w-[115%]',
|
||||
side === 'left' ? 'ml-auto lg:-ml-[12%]' : 'mr-auto lg:-mr-[12%]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,7 +25,8 @@ const steps = [
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: t('getStarted.step2.title', locale)
|
||||
title: t('getStarted.step2.title', locale),
|
||||
description: t('getStarted.step2.description', locale)
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
@@ -73,17 +74,6 @@ const steps = [
|
||||
>
|
||||
{{ step.description }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="step.number === '2'"
|
||||
class="text-primary-comfy-canvas flex-1 text-sm"
|
||||
>
|
||||
{{ t('getStarted.step2.descriptionPrefix', locale)
|
||||
}}<a
|
||||
:href="externalLinks.workflows"
|
||||
class="text-primary-comfy-yellow hover:underline"
|
||||
>{{ t('getStarted.step2.descriptionLink', locale) }}</a
|
||||
>{{ t('getStarted.step2.descriptionSuffix', locale) }}
|
||||
</p>
|
||||
<p v-if="step.links" class="flex-1 text-sm">
|
||||
<template v-for="(link, i) in step.links" :key="link.href">
|
||||
<a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
import { useTemplateRefsList } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -37,12 +37,6 @@ const badgeSegments = [
|
||||
|
||||
const activeIndex = ref(0)
|
||||
const videoRefs = useTemplateRefsList<HTMLVideoElement>()
|
||||
const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
|
||||
const isVisible = ref(false)
|
||||
|
||||
useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
isVisible.value = entry?.isIntersecting ?? false
|
||||
})
|
||||
|
||||
watch(activeIndex, (current, previous) => {
|
||||
videoRefs.value[previous]?.pause()
|
||||
@@ -55,7 +49,7 @@ watch(activeIndex, (current, previous) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
|
||||
<section class="px-4 py-20 lg:px-20 lg:py-24">
|
||||
<!-- Section header -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<NodeBadge :segments="badgeSegments" segment-class="" />
|
||||
@@ -72,13 +66,23 @@ watch(activeIndex, (current, previous) => {
|
||||
<!-- Video area (desktop only) -->
|
||||
<div class="hidden flex-1 lg:flex">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-5xl relative flex w-full items-center justify-center overflow-hidden p-0.5',
|
||||
isVisible && 'animate-border-spin'
|
||||
)
|
||||
"
|
||||
class="rounded-5xl relative flex w-full items-center justify-center overflow-hidden p-0.5 [clip-path:inset(0_round_var(--radius-5xl))]"
|
||||
>
|
||||
<div
|
||||
class="animate-border-spin absolute top-1/2 left-1/2 aspect-square min-h-full min-w-full -translate-1/2 scale-150"
|
||||
style="
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--color-primary-comfy-yellow) 4%,
|
||||
transparent
|
||||
)
|
||||
0%,
|
||||
var(--color-primary-comfy-yellow) 100%
|
||||
);
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="bg-primary-comfy-ink relative size-full overflow-hidden rounded-[calc(2.5rem-2px)]"
|
||||
>
|
||||
@@ -88,13 +92,12 @@ watch(activeIndex, (current, previous) => {
|
||||
:key="feature.title"
|
||||
:src="feature.video"
|
||||
:autoplay="i === 0"
|
||||
:preload="i === 0 ? 'metadata' : 'none'"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full object-cover transition-opacity duration-300 will-change-[opacity]',
|
||||
'absolute inset-0 size-full object-cover transition-opacity duration-300',
|
||||
activeIndex === i ? 'opacity-100' : 'opacity-0'
|
||||
)
|
||||
"
|
||||
@@ -109,24 +112,21 @@ watch(activeIndex, (current, previous) => {
|
||||
<!-- Video area (mobile, rendered before active item) -->
|
||||
<div
|
||||
v-if="activeIndex === i"
|
||||
:class="cn('aspect-video lg:hidden', i !== 0 && 'mt-4')"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-video overflow-hidden rounded-4xl lg:hidden',
|
||||
i !== 0 && 'mt-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="animate-border-spin size-full overflow-hidden rounded-4xl p-0.5"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-comfy-ink size-full overflow-hidden rounded-[calc(2rem-2px)]"
|
||||
>
|
||||
<video
|
||||
:src="feature.video"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<video
|
||||
:src="feature.video"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Connector (mobile) -->
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useParallax } from '../../composables/useParallax'
|
||||
import { usePinScrub, VH_PER_ITEM } from '../../composables/usePinScrub'
|
||||
import { usePinScrub } from '../../composables/usePinScrub'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import BlobMedia from './BlobMedia.vue'
|
||||
import BlobRail from './BlobRail.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
interface Category {
|
||||
label: string
|
||||
leftSrc: string
|
||||
rightSrc: string
|
||||
rightObjectPosition?: 'top' | 'bottom' | 'center'
|
||||
}
|
||||
|
||||
const categories: Category[] = [
|
||||
const categories = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
@@ -50,6 +40,10 @@ const categories: Category[] = [
|
||||
}
|
||||
]
|
||||
|
||||
function isVideo(src: string): boolean {
|
||||
return src.endsWith('.webm')
|
||||
}
|
||||
|
||||
const sectionRef = ref<HTMLElement>()
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const navRef = ref<HTMLElement>()
|
||||
@@ -58,8 +52,7 @@ const rightImgRef = ref<HTMLElement>()
|
||||
|
||||
const {
|
||||
activeIndex: activeCategory,
|
||||
isEnabled,
|
||||
isPinned,
|
||||
isActive: isPinned,
|
||||
scrollToIndex
|
||||
} = usePinScrub(
|
||||
{ section: sectionRef, content: contentRef, nav: navRef },
|
||||
@@ -76,57 +69,15 @@ const uid = useId()
|
||||
const leftBlobId = `left-blob-${uid}`
|
||||
const rightBlobId = `right-blob-${uid}`
|
||||
|
||||
function navButtons() {
|
||||
return navRef.value?.querySelectorAll<HTMLButtonElement>(':scope > button')
|
||||
}
|
||||
|
||||
whenever(isPinned, () => {
|
||||
navButtons()?.[activeCategory.value]?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
watch(activeCategory, (index) => {
|
||||
if (!isPinned.value) return
|
||||
navButtons()?.[index]?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
function onNavKeydown(event: KeyboardEvent) {
|
||||
const delta = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0
|
||||
if (!delta) return
|
||||
|
||||
event.preventDefault()
|
||||
const current = activeCategory.value
|
||||
const next = current + delta
|
||||
|
||||
if (next < 0 || next >= categories.length) {
|
||||
navButtons()?.[current]?.blur()
|
||||
return
|
||||
}
|
||||
|
||||
scrollToIndex(next)
|
||||
navButtons()?.[next]?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function travelRange(el: HTMLElement) {
|
||||
if (window.matchMedia('(min-width: 1024px)').matches) return 150
|
||||
|
||||
const rail = el.parentElement?.parentElement
|
||||
if (!rail) return 0
|
||||
const pb = parseFloat(getComputedStyle(rail).paddingBottom)
|
||||
return Math.max(0, (rail.clientHeight - pb - el.offsetHeight) / 2)
|
||||
}
|
||||
|
||||
const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%`
|
||||
const pinScrubEnd = `+=${categories.length * 100}%`
|
||||
useParallax([rightImgRef], {
|
||||
trigger: sectionRef,
|
||||
fromY: (el) => -travelRange(el),
|
||||
y: (el) => travelRange(el),
|
||||
start: 'top top',
|
||||
end: pinScrubEnd
|
||||
})
|
||||
useParallax([leftImgRef], {
|
||||
trigger: sectionRef,
|
||||
fromY: (el) => travelRange(el),
|
||||
y: (el) => -travelRange(el),
|
||||
y: -60,
|
||||
start: 'top top',
|
||||
end: pinScrubEnd
|
||||
})
|
||||
@@ -137,11 +88,12 @@ useParallax([leftImgRef], {
|
||||
ref="sectionRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
|
||||
isEnabled && 'lg:h-[calc(100vh+60px)]'
|
||||
'bg-primary-comfy-ink relative isolate px-8 py-20 lg:px-0 lg:py-24',
|
||||
isPinned && 'overflow-x-clip lg:h-[calc(100vh+60px)]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Clip-path definitions for shaped images -->
|
||||
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath :id="leftBlobId" clipPathUnits="objectBoundingBox">
|
||||
@@ -158,35 +110,64 @@ useParallax([leftImgRef], {
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="relative mx-auto grid w-full grid-cols-[minmax(0,5rem)_minmax(18ch,1fr)_minmax(0,5rem)] grid-rows-[auto_minmax(0,1fr)_auto] lg:h-full lg:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)_minmax(0,1fr)] lg:gap-x-0"
|
||||
class="relative mx-auto grid w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)] lg:h-full lg:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)_minmax(0,1fr)]"
|
||||
>
|
||||
<!-- Label row -->
|
||||
<div class="relative z-20 col-span-full flex justify-center py-4">
|
||||
<!-- Label row spanning all columns -->
|
||||
<div
|
||||
class="from-primary-comfy-ink to-primary-comfy-ink/10 relative z-20 col-span-full bg-linear-to-b py-4"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-yellow from-primary-comfy-ink to-primary-comfy-ink/10 shrink grow-0 bg-linear-to-b text-center text-sm font-bold tracking-widest uppercase lg:text-base"
|
||||
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase lg:text-base"
|
||||
>
|
||||
{{ t('useCase.label', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Left blob rail -->
|
||||
<BlobRail side="left">
|
||||
<div ref="leftImgRef" class="size-full will-change-transform">
|
||||
<BlobMedia :src="activeLeft" :clip-id="leftBlobId" />
|
||||
<!-- Left image -->
|
||||
<div
|
||||
class="pointer-events-none relative hidden min-h-0 lg:flex lg:items-center lg:justify-start"
|
||||
>
|
||||
<div class="w-[115%] -translate-x-[12%]">
|
||||
<div
|
||||
ref="leftImgRef"
|
||||
class="relative h-[72vh] max-h-240 w-full overflow-hidden will-change-transform"
|
||||
:style="`clip-path: url(#${leftBlobId})`"
|
||||
>
|
||||
<Transition name="crossfade">
|
||||
<video
|
||||
v-if="isVideo(activeLeft)"
|
||||
:key="`video-${activeLeft}`"
|
||||
:src="activeLeft"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:key="`img-${activeLeft}`"
|
||||
:src="activeLeft"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</BlobRail>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="relative z-10 min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex h-full flex-col items-center will-change-transform"
|
||||
class="flex flex-col items-center will-change-transform"
|
||||
>
|
||||
<nav
|
||||
ref="navRef"
|
||||
class="mt-16 flex w-full max-w-5/6 flex-1 flex-col items-center justify-evenly gap-12 lg:mt-[clamp(0.5rem,3vh,5rem)] lg:max-w-none lg:gap-[clamp(0.25rem,1vh,2rem)]"
|
||||
:aria-label="t('useCase.navLabel', locale)"
|
||||
@keydown="onNavKeydown"
|
||||
class="mt-16 flex w-full max-w-5/6 flex-col items-center justify-center gap-12 lg:mt-20 lg:max-w-none lg:gap-8"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
@@ -194,7 +175,7 @@ useParallax([leftImgRef], {
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer text-center text-2xl font-light whitespace-pre-line transition-colors outline-none lg:text-[clamp(1rem,5vh,3rem)]',
|
||||
'lg:text-4.5xl cursor-pointer text-center text-4xl font-light whitespace-pre-line transition-colors',
|
||||
index === activeCategory
|
||||
? 'text-primary-comfy-canvas'
|
||||
: 'text-primary-comfy-canvas/30 hover:text-primary-comfy-canvas/50'
|
||||
@@ -206,29 +187,66 @@ useParallax([leftImgRef], {
|
||||
{{ category.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-gray mt-20 max-w-md text-center text-base"
|
||||
>
|
||||
{{ t('useCase.body', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
class="mt-8"
|
||||
>
|
||||
{{ t('useCase.cta', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right blob rail -->
|
||||
<BlobRail side="right">
|
||||
<div ref="rightImgRef" class="size-full will-change-transform">
|
||||
<BlobMedia
|
||||
:src="activeRight"
|
||||
:clip-id="rightBlobId"
|
||||
:object-position="activeRightObjectPosition"
|
||||
/>
|
||||
</div>
|
||||
</BlobRail>
|
||||
<!-- Right image -->
|
||||
<div
|
||||
class="col-span-full mt-8 flex flex-col items-center gap-8 px-4 lg:mt-[clamp(0.25rem,1vh,2rem)] lg:gap-[clamp(0.25rem,1vh,2rem)]"
|
||||
class="pointer-events-none relative hidden min-h-0 lg:flex lg:items-center lg:justify-end"
|
||||
>
|
||||
<p class="text-primary-warm-gray max-w-md text-center text-base">
|
||||
{{ t('useCase.body', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton :href="externalLinks.workflows" variant="outline">
|
||||
{{ t('useCase.cta', locale) }}
|
||||
</BrandButton>
|
||||
<div class="w-[115%] translate-x-[12%]">
|
||||
<div
|
||||
ref="rightImgRef"
|
||||
class="relative h-[72vh] max-h-240 w-full overflow-hidden will-change-transform"
|
||||
:style="`clip-path: url(#${rightBlobId})`"
|
||||
>
|
||||
<Transition name="crossfade">
|
||||
<video
|
||||
v-if="isVideo(activeRight)"
|
||||
:key="`video-${activeRight}`"
|
||||
:src="activeRight"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
:style="
|
||||
activeRightObjectPosition
|
||||
? `object-position: ${activeRightObjectPosition}`
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:key="`img-${activeRight}`"
|
||||
:src="activeRight"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
:style="
|
||||
activeRightObjectPosition
|
||||
? `object-position: ${activeRightObjectPosition}`
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
@@ -98,7 +104,14 @@ const plans: PricingPlan[] = [
|
||||
summaryKey: 'pricing.enterprise.description',
|
||||
ctaKey: 'pricing.enterprise.cta',
|
||||
ctaHref: getRoutes(locale).cloudEnterprise,
|
||||
features: [],
|
||||
featureIntroKey: 'pricing.enterprise.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.enterprise.feature1' },
|
||||
{ text: 'pricing.enterprise.feature2' },
|
||||
{ text: 'pricing.enterprise.feature3' },
|
||||
{ text: 'pricing.enterprise.feature4' }
|
||||
],
|
||||
andMoreKey: 'pricing.enterprise.andMore',
|
||||
isEnterprise: true
|
||||
}
|
||||
]
|
||||
@@ -216,7 +229,7 @@ const activePlanIndex = ref(0)
|
||||
<div v-else class="px-6" />
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<div class="px-6 py-3">
|
||||
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
|
||||
{{
|
||||
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' '
|
||||
@@ -343,10 +356,7 @@ const activePlanIndex = ref(0)
|
||||
</div>
|
||||
|
||||
<!-- Features card -->
|
||||
<div
|
||||
v-if="plan.features.length"
|
||||
class="bg-transparency-white-t4 mt-2 rounded-3xl p-6"
|
||||
>
|
||||
<div class="bg-transparency-white-t4 mt-2 rounded-3xl p-6">
|
||||
<PricingPlanFeatureList
|
||||
:features="plan.features"
|
||||
:feature-intro-key="plan.featureIntroKey"
|
||||
|
||||
@@ -1,59 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
interface IncludedFeature {
|
||||
titleKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
title: string
|
||||
description: string
|
||||
isComingSoon?: boolean
|
||||
}
|
||||
|
||||
const features: IncludedFeature[] = [
|
||||
{
|
||||
titleKey: 'pricing.included.feature1.title',
|
||||
descriptionKey: 'pricing.included.feature1.description'
|
||||
title: 'Machine Setup',
|
||||
description: 'Comfy Cloud runs on Blackwell RTX 6000 Pro – 96GB VRAM'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature2.title',
|
||||
descriptionKey: 'pricing.included.feature2.description'
|
||||
title: 'Time limit per job',
|
||||
description:
|
||||
'Each workflow run has a maximum duration of 30 minutes. On the Pro plan, the time limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature3.title',
|
||||
descriptionKey: 'pricing.included.feature3.description'
|
||||
title: 'Usage',
|
||||
description:
|
||||
"You're only charged for <strong>active GPU</strong> time while a workflow is running. Idle time (e.g. time spent building workflows) does not consume GPU hours."
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature4.title',
|
||||
descriptionKey: 'pricing.included.feature4.description'
|
||||
title: 'Credit balance',
|
||||
description:
|
||||
'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature5.title',
|
||||
descriptionKey: 'pricing.included.feature5.description'
|
||||
title: 'Add more credits anytime',
|
||||
description:
|
||||
'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature6.title',
|
||||
descriptionKey: 'pricing.included.feature6.description'
|
||||
title: 'Pre-installed models',
|
||||
description: 'Access a library of 900+ pre-installed models.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature7.title',
|
||||
descriptionKey: 'pricing.included.feature7.description'
|
||||
title: 'Custom nodes support',
|
||||
description:
|
||||
'Comfy Cloud currently supports a variety of the most-used custom nodes from the ComfyUI community. <a href="https://docs.comfy.org" class="text-primary-comfy-yellow underline">Check out which nodes we support currently.</a> We\'re expanding support regularly based on demand and compatibility. <a href="https://docs.comfy.org" class="text-primary-comfy-yellow underline">See which nodes we\'re working on adding.</a>'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature8.title',
|
||||
descriptionKey: 'pricing.included.feature8.description'
|
||||
title: 'Partner Nodes',
|
||||
description:
|
||||
'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature9.title',
|
||||
descriptionKey: 'pricing.included.feature9.description'
|
||||
title: 'Job queue',
|
||||
description: 'Queue up to 100 workflows at once.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature10.title',
|
||||
descriptionKey: 'pricing.included.feature10.description'
|
||||
title: 'Custom LoRA importing',
|
||||
description:
|
||||
'For those on the Creator or Pro plans, you can bring in your own models & LoRAs from CivitAI or Huggingface to perfect your own style.'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
title: 'Parallel job execution',
|
||||
description:
|
||||
'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -77,7 +85,7 @@ const features: IncludedFeature[] = [
|
||||
<div>
|
||||
<div
|
||||
v-for="(feature, index) in features"
|
||||
:key="feature.titleKey"
|
||||
:key="feature.title"
|
||||
:class="
|
||||
index < features.length - 1
|
||||
? 'border-primary-comfy-canvas/15 border-b border-solid'
|
||||
@@ -103,13 +111,13 @@ const features: IncludedFeature[] = [
|
||||
/>
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
{{ feature.title }}
|
||||
</p>
|
||||
<span
|
||||
v-if="feature.isComingSoon"
|
||||
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
|
||||
>
|
||||
{{ t('pricing.included.comingSoon', locale) }}
|
||||
coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +125,7 @@ const features: IncludedFeature[] = [
|
||||
<!-- Description -->
|
||||
<p
|
||||
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
|
||||
v-html="t(feature.descriptionKey, locale)"
|
||||
v-html="feature.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const features = [
|
||||
{
|
||||
title: t('api.automation.feature1.title', locale),
|
||||
description: t('api.automation.feature1.description', locale),
|
||||
image: 'https://media.comfy.org/website/api/infrastructure-nodes.webp',
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
description2: t('api.automation.feature1.description2', locale)
|
||||
},
|
||||
{
|
||||
@@ -22,7 +22,7 @@ const features = [
|
||||
{
|
||||
title: t('api.automation.feature3.title', locale),
|
||||
description: t('api.automation.feature3.description', locale),
|
||||
image: 'https://media.comfy.org/website/api/precision-tools.webp'
|
||||
image: 'https://media.comfy.org/website/pricing/free.webp'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -223,14 +223,14 @@ onUnmounted(() => {
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
|
||||
<BrandButton
|
||||
:href="externalLinks.apiKeys"
|
||||
:href="externalLinks.cloud"
|
||||
size="lg"
|
||||
class="text-center lg:min-w-60 lg:p-4"
|
||||
>
|
||||
{{ t('api.hero.getApiKeys', locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
:href="externalLinks.docsApi"
|
||||
:href="externalLinks.docs"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="text-center lg:min-w-60 lg:p-4"
|
||||
|
||||
@@ -13,13 +13,13 @@ const steps = [
|
||||
number: '01',
|
||||
titleKey: 'api.steps.step1.title' as const,
|
||||
descriptionKey: 'api.steps.step1.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-purple.webp'
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
titleKey: 'api.steps.step2.title' as const,
|
||||
descriptionKey: 'api.steps.step2.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
@@ -61,7 +61,7 @@ const steps = [
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<BrandButton
|
||||
:href="externalLinks.apiKeys"
|
||||
:href="externalLinks.cloud"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="w-full text-center lg:w-auto lg:min-w-48"
|
||||
@@ -69,7 +69,7 @@ const steps = [
|
||||
{{ t('api.hero.getApiKeys', locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
:href="externalLinks.docsApi"
|
||||
:href="externalLinks.docs"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full text-center lg:w-auto lg:min-w-48"
|
||||
|
||||
@@ -10,12 +10,12 @@ const cards = [
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card1.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card1.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-purple.webp'
|
||||
},
|
||||
{
|
||||
titleKey: 'enterprise.byoKey.card2.title' as const,
|
||||
descriptionKey: 'enterprise.byoKey.card2.description' as const,
|
||||
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
|
||||
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -16,11 +16,9 @@ const midRightRef = ref<HTMLElement>()
|
||||
const bottomLeftRef = ref<HTMLElement>()
|
||||
const bottomRightRef = ref<HTMLElement>()
|
||||
|
||||
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
|
||||
|
||||
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
|
||||
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
|
||||
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
|
||||
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
|
||||
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
|
||||
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -44,303 +44,162 @@ onMounted(() => {
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="block size-full"
|
||||
viewBox="0 0 1600 1046"
|
||||
viewBox="600 -50 1000 1100"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g clip-path="url(#enterpriseHeroClip)">
|
||||
<rect width="1600" height="1046" fill="#211927" />
|
||||
<rect
|
||||
width="800"
|
||||
height="800"
|
||||
transform="translate(712 112)"
|
||||
fill="#211927"
|
||||
/>
|
||||
<!-- Ripple rings -->
|
||||
<path
|
||||
class="ripple-path"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-1"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-2"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path ripple-delay-3"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Ripple rings -->
|
||||
<!-- Exploding block cluster -->
|
||||
<g stroke="#4D3762" stroke-width="2">
|
||||
<path
|
||||
class="ripple-path"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path delay-1"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path delay-2"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="ripple-path delay-3"
|
||||
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
class="block-piece"
|
||||
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g class="block-cluster">
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#F2FF59"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
|
||||
fill="#251D2B"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
|
||||
fill="#37303F"
|
||||
stroke="#4D3762"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
width="422.621"
|
||||
height="1125.11"
|
||||
transform="matrix(-1 0 0 1 909.219 9.26587)"
|
||||
fill="url(#enterpriseHeroFade)"
|
||||
style="pointer-events: none"
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
|
||||
fill="#F2FF59"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
|
||||
fill="#251D2B"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
|
||||
fill="#37303F"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
width="422.621"
|
||||
height="1125.11"
|
||||
transform="matrix(-1 0 0 1 909.219 9.26587)"
|
||||
fill="url(#enterpriseHeroFade)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="enterpriseHeroFade"
|
||||
@@ -353,9 +212,6 @@ onMounted(() => {
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
<clipPath id="enterpriseHeroClip">
|
||||
<rect width="1600" height="1046" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -399,13 +255,13 @@ onMounted(() => {
|
||||
animation: ripple-effect 4s linear infinite;
|
||||
}
|
||||
|
||||
.delay-1 {
|
||||
.ripple-delay-1 {
|
||||
animation-delay: -1s;
|
||||
}
|
||||
.delay-2 {
|
||||
.ripple-delay-2 {
|
||||
animation-delay: -2s;
|
||||
}
|
||||
.delay-3 {
|
||||
.ripple-delay-3 {
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
@@ -425,11 +281,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.block-cluster {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
.block-piece {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
|
||||
@@ -21,12 +21,17 @@ const features = computed(() => [
|
||||
{
|
||||
title: t('enterprise.team.feature2.title', locale),
|
||||
description: t('enterprise.team.feature2.description', locale),
|
||||
image: 'https://media.comfy.org/website/enterprise/dark-fluid-texture.webp'
|
||||
image: 'https://media.comfy.org/website/enterprise/dark-fluid-texture.webp',
|
||||
ctaText: t('enterprise.team.feature2.cta', locale),
|
||||
ctaHref: routes.value.cloud
|
||||
},
|
||||
{
|
||||
title: t('enterprise.team.feature3.title', locale),
|
||||
description: t('enterprise.team.feature3.description', locale),
|
||||
image: 'https://media.comfy.org/website/enterprise/isometric-interface.webp'
|
||||
image:
|
||||
'https://media.comfy.org/website/enterprise/isometric-interface.webp',
|
||||
ctaText: t('enterprise.hero.contactSales', locale),
|
||||
ctaHref: routes.value.contact
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -40,16 +40,17 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-4.5xl w-full flex-1 overflow-hidden">
|
||||
<video
|
||||
src="https://media.comfy.org/website/local/racer.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<!-- TODO: Replace with final ecosystem illustration -->
|
||||
<div
|
||||
class="rounded-4.5xl aspect-4/3 w-full flex-1 overflow-hidden bg-linear-to-b from-emerald-300 to-amber-200"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow rounded-full px-4 py-2 text-sm font-bold"
|
||||
>
|
||||
4x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -164,11 +164,11 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-visible lg:flex-row lg:items-center lg:pb-[min(8vw,10rem)]"
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
|
||||
>
|
||||
<!-- Illustration (stacks above on mobile, left on lg) -->
|
||||
<div
|
||||
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-[80px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
@@ -187,7 +187,7 @@ onUnmounted(() => {
|
||||
rx="65.5036"
|
||||
transform="matrix(-0.866025 -0.5 0 -1 620.969 1058.01)"
|
||||
fill="#211927"
|
||||
stroke="#4D3762"
|
||||
stroke="#7E7C78"
|
||||
stroke-width="3"
|
||||
visibility="hidden"
|
||||
/>
|
||||
@@ -200,7 +200,7 @@ onUnmounted(() => {
|
||||
rx="59.4123"
|
||||
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
|
||||
fill="#211927"
|
||||
stroke="#4D3762"
|
||||
stroke="#7E7C78"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<rect
|
||||
@@ -212,7 +212,7 @@ onUnmounted(() => {
|
||||
rx="59.4123"
|
||||
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
|
||||
fill="#211927"
|
||||
stroke="#4D3762"
|
||||
stroke="#7E7C78"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<rect
|
||||
@@ -224,12 +224,12 @@ onUnmounted(() => {
|
||||
rx="59.4123"
|
||||
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
|
||||
fill="#211927"
|
||||
stroke="#4D3762"
|
||||
stroke="#7E7C78"
|
||||
stroke-width="3"
|
||||
/>
|
||||
|
||||
<!-- Hex nodes -->
|
||||
<g stroke="#4D3762" stroke-width="3">
|
||||
<g stroke="#7E7C78" stroke-width="6">
|
||||
<path
|
||||
data-hex="5"
|
||||
d="M722.595 427.826L722.579 491.728C722.576 500.223 728.536 510.551 735.889 514.796L791.205 546.733C795.862 549.422 804.238 549.298 808.894 546.607L864.227 514.642C871.583 510.392 877.548 500.061 877.55 491.566L877.567 427.664C877.568 422.286 873.487 414.972 868.829 412.283L813.514 380.347C806.16 376.101 794.236 376.104 786.88 380.354L731.548 412.319C726.882 415.015 722.597 422.437 722.595 427.826Z"
|
||||
@@ -283,12 +283,11 @@ onUnmounted(() => {
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
fill="url(#localHeroFade)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
id="localHeroFade"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
|
||||
@@ -12,12 +12,10 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
<p
|
||||
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
|
||||
>
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('download.cloud.prefix', locale) }}
|
||||
</span>
|
||||
{{ t('download.cloud.prefix', locale) }}
|
||||
<a
|
||||
:href="externalLinks.cloud"
|
||||
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest whitespace-nowrap uppercase hover:underline"
|
||||
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest uppercase hover:underline"
|
||||
>
|
||||
{{ t('download.cloud.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -29,14 +29,14 @@ const {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
|
||||
class="flex flex-col gap-4 px-4 py-24 md:flex-row md:gap-16 md:px-20 md:py-40"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 lg:top-28 lg:w-115 lg:py-0"
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-115 md:py-0"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl/16 font-light whitespace-pre-line lg:text-5xl/16"
|
||||
class="text-primary-comfy-canvas text-4xl/16 font-light whitespace-pre-line md:text-5xl/16"
|
||||
>
|
||||
{{ t(headingKey, locale)
|
||||
}}<span v-if="headingHighlightKey" :class="highlightClass">{{
|
||||
@@ -56,13 +56,14 @@ const {
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason.titleKey"
|
||||
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 lg:flex-row lg:gap-12"
|
||||
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 md:flex-row md:gap-12"
|
||||
>
|
||||
<div class="shrink-0 lg:w-84">
|
||||
<div class="shrink-0 md:w-52">
|
||||
<h3
|
||||
class="text-primary-comfy-canvas text-2xl font-light whitespace-pre-line"
|
||||
v-html="t(reason.titleKey, locale)"
|
||||
/>
|
||||
>
|
||||
{{ t(reason.titleKey, locale) }}
|
||||
</h3>
|
||||
<slot name="reason-extra" :reason="reason" />
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas/70 flex-1 text-sm">
|
||||
|
||||
@@ -3,68 +3,45 @@ import { onMounted, onUnmounted } from 'vue'
|
||||
import { gsap } from '../scripts/gsapSetup'
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
type ValueOrFn = number | ((el: HTMLElement, trigger: HTMLElement) => number)
|
||||
|
||||
function resolve(v: ValueOrFn, el: HTMLElement, trigger: HTMLElement): number {
|
||||
return typeof v === 'function' ? v(el, trigger) : v
|
||||
}
|
||||
|
||||
interface ParallaxOptions {
|
||||
/** Starting vertical offset in pixels, or a function resolved at mount */
|
||||
fromY?: ValueOrFn
|
||||
/** Ending vertical offset in pixels, or a function resolved at mount */
|
||||
y?: ValueOrFn
|
||||
/** Vertical offset in pixels (default: 200) */
|
||||
y?: number
|
||||
trigger?: Ref<HTMLElement | undefined>
|
||||
/** ScrollTrigger start value (default: 'top bottom') */
|
||||
start?: string
|
||||
/** ScrollTrigger end value (default: 'bottom top') */
|
||||
end?: string
|
||||
/** Media query string — animation only runs when matched (responsive) */
|
||||
mediaQuery?: string
|
||||
}
|
||||
|
||||
export function useParallax(
|
||||
elements: Ref<HTMLElement | undefined>[],
|
||||
options: ParallaxOptions = {}
|
||||
) {
|
||||
const { fromY = 0, y = 200 } = options
|
||||
let ctx: gsap.Context | gsap.MatchMedia | undefined
|
||||
const { y = 200 } = options
|
||||
let ctx: gsap.Context | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
|
||||
const triggerEl = options.trigger?.value
|
||||
|
||||
const createAnimations = () => {
|
||||
const els = elements
|
||||
.map((r) => r.value)
|
||||
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
|
||||
if (!els.length) return
|
||||
|
||||
const trigger = triggerEl ?? els[0]
|
||||
const scrollTrigger = {
|
||||
trigger,
|
||||
start: options.start ?? 'top bottom',
|
||||
end: options.end ?? 'bottom top',
|
||||
scrub: 1
|
||||
}
|
||||
const trigger = options.trigger?.value
|
||||
const els = elements
|
||||
.map((r) => r.value)
|
||||
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
|
||||
if (!els.length || prefersReducedMotion()) return
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
els.forEach((el) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
{ y: resolve(fromY, el, trigger) },
|
||||
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
|
||||
)
|
||||
gsap.to(el, {
|
||||
y,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: trigger ?? el,
|
||||
start: options.start ?? 'top bottom',
|
||||
end: options.end ?? 'bottom top',
|
||||
scrub: 1,
|
||||
invalidateOnRefresh: true
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (options.mediaQuery) {
|
||||
const mm = gsap.matchMedia()
|
||||
mm.add(options.mediaQuery, createAnimations)
|
||||
ctx = mm
|
||||
} else {
|
||||
ctx = gsap.context(createAnimations)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { ScrollTrigger } from '../scripts/gsapSetup'
|
||||
import { gsap } from '../scripts/gsapSetup'
|
||||
import { scrollTo } from '../scripts/smoothScroll'
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
@@ -13,13 +12,10 @@ interface PinScrubRefs {
|
||||
|
||||
interface PinScrubOptions {
|
||||
itemCount: number
|
||||
/** Viewport-height percentage per item (default: 20) */
|
||||
/** Viewport-height percentage per item (default: 100) */
|
||||
vhPerItem?: number
|
||||
}
|
||||
|
||||
/** Viewport-height percentage each category occupies in the scroll distance. */
|
||||
export const VH_PER_ITEM = 20
|
||||
|
||||
function interpolateY(
|
||||
index: number,
|
||||
buttonCenters: number[],
|
||||
@@ -42,12 +38,11 @@ function interpolateY(
|
||||
|
||||
export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
|
||||
const activeIndex = ref(0)
|
||||
const isEnabled = ref(false)
|
||||
const isPinned = ref(false)
|
||||
const isActive = ref(false)
|
||||
let ctx: gsap.Context | undefined
|
||||
let scrollTriggerInstance: ScrollTrigger | undefined
|
||||
|
||||
const vhPerItem = options.vhPerItem ?? VH_PER_ITEM
|
||||
const vhPerItem = options.vhPerItem ?? 100
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
if (!scrollTriggerInstance) {
|
||||
@@ -82,14 +77,6 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
|
||||
const sectionStyle = getComputedStyle(section)
|
||||
contentH = content.scrollHeight
|
||||
vpH = window.innerHeight - parseFloat(sectionStyle.paddingTop)
|
||||
|
||||
// Ensure the section is tall enough for its content so nothing clips
|
||||
const naturalH = section.scrollHeight
|
||||
const viewportH = window.innerHeight + 60
|
||||
if (naturalH > viewportH) {
|
||||
section.style.height = `${naturalH}px`
|
||||
}
|
||||
|
||||
buttonCenters = Array.from(nav.querySelectorAll(':scope > *')).map(
|
||||
(btn) => {
|
||||
const btnRect = btn.getBoundingClientRect()
|
||||
@@ -99,50 +86,45 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
|
||||
}
|
||||
|
||||
cacheLayout()
|
||||
isEnabled.value = true
|
||||
const setContentY = gsap.quickSetter(content, 'y', 'px')
|
||||
isActive.value = true
|
||||
|
||||
const proxy = { index: 0 }
|
||||
ctx = gsap.context(() => {
|
||||
const tween = gsap.to(
|
||||
{},
|
||||
{
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top top',
|
||||
end: `+=${options.itemCount * vhPerItem}%`,
|
||||
pin: true,
|
||||
scrub: true,
|
||||
onToggle(self) {
|
||||
isPinned.value = self.isActive
|
||||
},
|
||||
onRefresh: cacheLayout,
|
||||
onUpdate(self) {
|
||||
const index = self.progress * (options.itemCount - 1)
|
||||
const nextActive = Math.round(index)
|
||||
|
||||
if (nextActive !== activeIndex.value) {
|
||||
activeIndex.value = nextActive
|
||||
}
|
||||
|
||||
if (contentH <= vpH) {
|
||||
setContentY(0)
|
||||
return
|
||||
}
|
||||
|
||||
setContentY(interpolateY(index, buttonCenters, contentH, vpH))
|
||||
}
|
||||
gsap.to(proxy, {
|
||||
index: options.itemCount - 1,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top top',
|
||||
end: `+=${options.itemCount * vhPerItem}%`,
|
||||
pin: true,
|
||||
scrub: true,
|
||||
refreshPriority: 1,
|
||||
onRefresh: cacheLayout,
|
||||
onUpdate(self) {
|
||||
scrollTriggerInstance = self
|
||||
}
|
||||
},
|
||||
onUpdate() {
|
||||
activeIndex.value = Math.round(proxy.index)
|
||||
|
||||
if (contentH <= vpH) {
|
||||
gsap.set(content, { y: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
gsap.set(content, {
|
||||
y: interpolateY(proxy.index, buttonCenters, contentH, vpH)
|
||||
})
|
||||
}
|
||||
)
|
||||
scrollTriggerInstance = tween.scrollTrigger as ScrollTrigger
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isEnabled.value = false
|
||||
isActive.value = false
|
||||
ctx?.revert()
|
||||
})
|
||||
|
||||
return { activeIndex, isEnabled, isPinned, scrollToIndex }
|
||||
return { activeIndex, isActive, scrollToIndex }
|
||||
}
|
||||
|
||||
@@ -27,14 +27,11 @@ export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
}
|
||||
|
||||
export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
platform: 'https://platform.comfy.org',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
} as const
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"fetchedAt": "2026-04-24T18:59:03.989Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "e915f2c78b17f93b",
|
||||
"title": "Senior Product Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
|
||||
},
|
||||
{
|
||||
"id": "b9f9a23219be7cd4",
|
||||
"title": "Design Engineer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "547b6ba622c800a5",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
|
||||
},
|
||||
{
|
||||
"id": "7bb02634a24763bc",
|
||||
"title": "Staff Product Designer - Systems",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ENGINEERING",
|
||||
"key": "engineering",
|
||||
"roles": [
|
||||
{
|
||||
"id": "102d58e35a8a9817",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
|
||||
},
|
||||
{
|
||||
"id": "d01d69fba7743905",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
|
||||
},
|
||||
{
|
||||
"id": "f36f60cfd5bb5910",
|
||||
"title": "Senior/Staff Applied Machine Learning Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
|
||||
},
|
||||
{
|
||||
"id": "9d8ec4c65e20b19e",
|
||||
"title": "Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
|
||||
},
|
||||
{
|
||||
"id": "be94b193d1f4d482",
|
||||
"title": "Tech Lead Manager, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
|
||||
},
|
||||
{
|
||||
"id": "ab48f5db6bd1783c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
|
||||
},
|
||||
{
|
||||
"id": "c5dff4ee628bdcd1",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
|
||||
},
|
||||
{
|
||||
"id": "4302a7aaa87e16e3",
|
||||
"title": "Product Manager, ComfyUI",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MARKETING",
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "b5803a0d4785d406",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
|
||||
},
|
||||
{
|
||||
"id": "130d7218d7895bdb",
|
||||
"title": "Partnership & Events Marketing Manager",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OPERATIONS",
|
||||
"key": "operations",
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Senior Technical Recruiter",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
},
|
||||
{
|
||||
"id": "16f556001ce1cef4",
|
||||
"title": "BizOps Strategist",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
|
||||
},
|
||||
{
|
||||
"id": "8e773a72c1b8e099",
|
||||
"title": "Founding Customer Success Manager",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface Role {
|
||||
id: string
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
applyUrl: string
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
name: string
|
||||
key: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
export interface RolesSnapshot {
|
||||
fetchedAt: string
|
||||
departments: Department[]
|
||||
}
|
||||
@@ -4,12 +4,12 @@ const translations = {
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
'zh-CN': '视觉 AI 的\n最强可控性'
|
||||
'zh-CN': '视觉 AI 的\n专业控制'
|
||||
},
|
||||
'hero.subtitle': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
@@ -20,11 +20,11 @@ const translations = {
|
||||
},
|
||||
'showcase.subtitle2': {
|
||||
en: 'Start from a community template or build from scratch.',
|
||||
'zh-CN': '从工作流模板开始,或从零构建。'
|
||||
'zh-CN': '从社区模板开始,或从零构建。'
|
||||
},
|
||||
'showcase.feature1.title': {
|
||||
en: 'Full Control with Nodes',
|
||||
'zh-CN': '节点带来的可控性'
|
||||
'zh-CN': '节点式完全控制'
|
||||
},
|
||||
'showcase.feature1.description': {
|
||||
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
|
||||
@@ -49,18 +49,14 @@ const translations = {
|
||||
'zh-CN':
|
||||
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
|
||||
},
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运作' },
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.label': {
|
||||
en: 'Industries that create with ComfyUI',
|
||||
'zh-CN': '使用 ComfyUI 创作的行业'
|
||||
},
|
||||
'useCase.navLabel': {
|
||||
en: 'Industry categories',
|
||||
'zh-CN': '行业分类'
|
||||
},
|
||||
'useCase.vfx': {
|
||||
en: 'VFX &\nAnimation',
|
||||
'zh-CN': '视觉特效 &\n动画'
|
||||
@@ -70,7 +66,7 @@ const translations = {
|
||||
'zh-CN': '广告 &\n创意工作室'
|
||||
},
|
||||
'useCase.gaming': {
|
||||
en: 'Gaming',
|
||||
en: 'Video Games',
|
||||
'zh-CN': '电子游戏'
|
||||
},
|
||||
'useCase.ecommerce': {
|
||||
@@ -83,7 +79,8 @@ const translations = {
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
|
||||
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
@@ -119,17 +116,9 @@ const translations = {
|
||||
en: 'Load a workflow',
|
||||
'zh-CN': '加载工作流'
|
||||
},
|
||||
'getStarted.step2.descriptionPrefix': {
|
||||
en: 'Start from ',
|
||||
'zh-CN': '从'
|
||||
},
|
||||
'getStarted.step2.descriptionLink': {
|
||||
en: 'a community template',
|
||||
'zh-CN': '社区模板'
|
||||
},
|
||||
'getStarted.step2.descriptionSuffix': {
|
||||
en: ' or build your own.',
|
||||
'zh-CN': '开始,或自行构建。'
|
||||
'getStarted.step2.description': {
|
||||
en: 'Start from a community template or build your own.',
|
||||
'zh-CN': '从社区模板开始,或自行构建。'
|
||||
},
|
||||
'getStarted.step3.title': {
|
||||
en: 'Generate & Iterate',
|
||||
@@ -163,7 +152,7 @@ const translations = {
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
'zh-CN': '查看本地版特性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -175,7 +164,7 @@ const translations = {
|
||||
},
|
||||
'products.cloud.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端属性'
|
||||
'zh-CN': '查看云端特性'
|
||||
},
|
||||
'products.api.title': {
|
||||
en: 'Comfy\nAPI',
|
||||
@@ -187,7 +176,7 @@ const translations = {
|
||||
},
|
||||
'products.api.cta': {
|
||||
en: 'SEE API FEATURES',
|
||||
'zh-CN': '查看 API 属性'
|
||||
'zh-CN': '查看 API 特性'
|
||||
},
|
||||
'products.enterprise.title': {
|
||||
en: 'Comfy\nEnterprise',
|
||||
@@ -199,7 +188,7 @@ const translations = {
|
||||
},
|
||||
'products.enterprise.cta': {
|
||||
en: 'SEE ENTERPRISE FEATURES',
|
||||
'zh-CN': '查看企业版属性'
|
||||
'zh-CN': '查看企业版特性'
|
||||
},
|
||||
|
||||
// CaseStudySpotlightSection
|
||||
@@ -208,12 +197,12 @@ const translations = {
|
||||
'zh-CN': '客户故事'
|
||||
},
|
||||
'caseStudy.heading': {
|
||||
en: 'See ComfyUI\nin the real world',
|
||||
'zh-CN': '看看 ComfyUI\n在真实世界中的应用'
|
||||
en: 'See Comfy\nin the real world',
|
||||
'zh-CN': '看看 Comfy\n在真实世界中的应用'
|
||||
},
|
||||
'caseStudy.subheading': {
|
||||
en: 'Videos & case studies from teams building with ComfyUI',
|
||||
'zh-CN': '来自使用 ComfyUI 构建的团队的视频和案例研究'
|
||||
en: 'Videos & case studies from teams building with Comfy',
|
||||
'zh-CN': '来自使用 Comfy 构建的团队的视频和案例研究'
|
||||
},
|
||||
'caseStudy.seeAll': {
|
||||
en: 'SEE ALL CASE STUDIES',
|
||||
@@ -741,7 +730,7 @@ const translations = {
|
||||
'zh-CN': 'Cloud'
|
||||
},
|
||||
'cloud.reason.1.title': {
|
||||
en: 'Powerful GPUs with <span class="whitespace-nowrap">end-to-end</span> security <span class="whitespace-nowrap">built-in</span>',
|
||||
en: 'Powerful GPUs with end-\nto-end security built-in',
|
||||
'zh-CN': '强大 GPU\n端到端安全内置'
|
||||
},
|
||||
'cloud.reason.1.description': {
|
||||
@@ -776,7 +765,7 @@ const translations = {
|
||||
'每个节点都可见。每个设置都可调。ComfyUI 为您提供完整的推理管线。选择您的采样器、调度器、模型链。云端简化了设置并增强了底层硬件。'
|
||||
},
|
||||
'cloud.reason.4.title': {
|
||||
en: 'Community workflows,\nunlimited customization\nthrough <span class="whitespace-nowrap">pre-installed</span>\ncustom nodes',
|
||||
en: 'Community workflows,\nunlimited customization\nthrough pre-installed\ncustom nodes',
|
||||
'zh-CN': '社区工作流,\n通过预安装自定义节点\n实现无限自定义'
|
||||
},
|
||||
'cloud.reason.4.description': {
|
||||
@@ -962,9 +951,9 @@ const translations = {
|
||||
'zh-CN': '我可以运行长时间或多个工作流吗?'
|
||||
},
|
||||
'cloud.faq.9.a': {
|
||||
en: "Each workflow can run for up to 60 minutes, with one active job at a time. We're adding higher tiers and parallel runs soon for even more flexibility.",
|
||||
en: "Each workflow can run for up to 30 minutes, with one active job at a time. We're adding higher tiers and parallel runs soon for even more flexibility.",
|
||||
'zh-CN':
|
||||
'每个工作流最长可运行 60 分钟,同时运行一个活跃任务。我们即将推出更高层级和并行运行,提供更大灵活性。'
|
||||
'每个工作流最长可运行 30 分钟,同时运行一个活跃任务。我们即将推出更高层级和并行运行,提供更大灵活性。'
|
||||
},
|
||||
'cloud.faq.10.q': {
|
||||
en: 'How is my user data stored and secured in Comfy Cloud?',
|
||||
@@ -1092,7 +1081,7 @@ const translations = {
|
||||
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
|
||||
'pricing.plan.creator.summary': {
|
||||
en: 'Small teams building fine-tuned, repeatable workflows',
|
||||
'zh-CN': '小团队构建精细调优、可复用的工作流'
|
||||
'zh-CN': '面向专业人士与小团队,构建可复用、精细调优的工作流'
|
||||
},
|
||||
'pricing.plan.creator.price': { en: '$35', 'zh-CN': '$35' },
|
||||
'pricing.plan.creator.credits': {
|
||||
@@ -1115,6 +1104,10 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: 'Up to 5 seats per workspace (coming soon!)',
|
||||
'zh-CN': '每个工作区最多 5 个席位(即将上线)'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1139,6 +1132,10 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: 'Up to 20 seats per workspace (coming soon!)',
|
||||
'zh-CN': '每个工作区最多 20 个席位(即将上线)'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
@@ -1149,7 +1146,7 @@ const translations = {
|
||||
en: 'For teams running Comfy in production, and at scale.',
|
||||
'zh-CN': '面向在生产环境和规模化场景中运行 Comfy 的团队。'
|
||||
},
|
||||
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '联系我们' },
|
||||
'pricing.enterprise.featureIntro': {
|
||||
en: 'Everything in Pro, plus:',
|
||||
'zh-CN': '包含专业版全部能力,另加:'
|
||||
@@ -1181,105 +1178,6 @@ const translations = {
|
||||
en: "What's included\nin the Comfy plan",
|
||||
'zh-CN': 'Comfy 计划\n包含哪些内容'
|
||||
},
|
||||
'pricing.included.feature1.title': {
|
||||
en: 'Machine Setup',
|
||||
'zh-CN': '机器配置'
|
||||
},
|
||||
'pricing.included.feature1.description': {
|
||||
en: 'Comfy Cloud runs on Blackwell RTX 6000 Pro – 96GB VRAM',
|
||||
'zh-CN': 'Comfy Cloud 运行在 Blackwell RTX 6000 Pro 上,配备 96GB 显存'
|
||||
},
|
||||
'pricing.included.feature2.title': {
|
||||
en: 'Time limit per job',
|
||||
'zh-CN': '单个任务时限'
|
||||
},
|
||||
'pricing.included.feature2.description': {
|
||||
en: 'On our Standard and Creator plans, each workflow has a maximum run time of 30 minutes. On the Pro plan, the limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
|
||||
'zh-CN':
|
||||
'Standard 和 Creator 计划下,每个工作流最长运行时间为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
|
||||
},
|
||||
'pricing.included.feature3.title': {
|
||||
en: 'Usage',
|
||||
'zh-CN': '用量计费'
|
||||
},
|
||||
'pricing.included.feature3.description': {
|
||||
en: "You're only charged for <strong>active GPU</strong> time while a workflow is running. Idle time (e.g. time spent building workflows) does not consume GPU hours.",
|
||||
'zh-CN':
|
||||
'仅在工作流运行期间按<strong>实际 GPU</strong> 使用时长计费。空闲时间(如构建工作流)不消耗 GPU 时长。'
|
||||
},
|
||||
'pricing.included.feature4.title': {
|
||||
en: 'Credit balance',
|
||||
'zh-CN': '积分余额'
|
||||
},
|
||||
'pricing.included.feature4.description': {
|
||||
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
|
||||
'zh-CN':
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro)。'
|
||||
},
|
||||
'pricing.included.feature5.title': {
|
||||
en: 'Add more credits anytime',
|
||||
'zh-CN': '随时加购积分'
|
||||
},
|
||||
'pricing.included.feature5.description': {
|
||||
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
|
||||
'zh-CN':
|
||||
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
|
||||
},
|
||||
'pricing.included.feature6.title': {
|
||||
en: 'Pre-installed models',
|
||||
'zh-CN': '预装模型'
|
||||
},
|
||||
'pricing.included.feature6.description': {
|
||||
en: 'Access a library of 900+ pre-installed models.',
|
||||
'zh-CN': '可访问 900+ 预装模型库。'
|
||||
},
|
||||
'pricing.included.feature7.title': {
|
||||
en: 'Custom nodes support',
|
||||
'zh-CN': '自定义节点支持'
|
||||
},
|
||||
'pricing.included.feature7.description': {
|
||||
en: "Comfy Cloud currently supports a variety of the most-used custom nodes from the ComfyUI community. We're expanding support regularly based on demand and compatibility.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud 目前支持 ComfyUI 社区中最常用的多种自定义节点,并根据需求和兼容性持续扩展支持范围。'
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
'zh-CN': '任务队列'
|
||||
},
|
||||
'pricing.included.feature9.description': {
|
||||
en: 'Queue up to 100 workflows at once.',
|
||||
'zh-CN': '可同时排队最多 100 个工作流。'
|
||||
},
|
||||
'pricing.included.feature10.title': {
|
||||
en: 'Custom LoRA importing',
|
||||
'zh-CN': '自定义 LoRA 导入'
|
||||
},
|
||||
'pricing.included.feature10.description': {
|
||||
en: 'For those on the Creator or Pro plans, you can bring in your own models & LoRAs from CivitAI or Huggingface to perfect your own style.',
|
||||
'zh-CN':
|
||||
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA,打造专属风格。'
|
||||
},
|
||||
'pricing.included.feature11.title': {
|
||||
en: 'Parallel job execution',
|
||||
'zh-CN': '并行任务执行'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
en: 'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
'zh-CN': '并行运行多个工作流,加速你的流程。'
|
||||
},
|
||||
'pricing.included.comingSoon': {
|
||||
en: 'coming soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
|
||||
// VideoPlayer
|
||||
'player.play': { en: 'Play', 'zh-CN': '播放' },
|
||||
@@ -1314,10 +1212,10 @@ const translations = {
|
||||
// ContactSection
|
||||
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
|
||||
'gallery.contact.heading': {
|
||||
en: 'Built something cool with ComfyUI? <a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Submit</a> your work to be featured on our website and socials and get seen by the global ComfyUI community.',
|
||||
'zh-CN':
|
||||
'用 ComfyUI 创作了很酷的作品?<a href="https://docs.google.com/forms/d/1B6_RPQfhTyKvqHk9OO2bUn8z1Qgh6QIZsF3GNMiCXDw/preview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">提交</a>你的作品,展示在我们的网站和社交媒体上,让全球 ComfyUI 社区看到。'
|
||||
en: 'Questions? Reach out!',
|
||||
'zh-CN': '有精彩作品想要分享?\n联系我们。'
|
||||
},
|
||||
'gallery.contact.cta': { en: 'Contact us', 'zh-CN': '联系我们' },
|
||||
|
||||
// AboutHeroSection
|
||||
'about.hero.label': { en: 'ABOUT', 'zh-CN': '关于' },
|
||||
@@ -1455,7 +1353,7 @@ const translations = {
|
||||
},
|
||||
|
||||
// CareersHeroSection
|
||||
'careers.hero.label': { en: 'CAREERS', 'zh-CN': '招聘' },
|
||||
'careers.hero.label': { en: 'CAREERS', 'zh-CN': 'CAREERS' },
|
||||
'careers.hero.heading': {
|
||||
en: 'Building an operating\nsystem for Gen AI',
|
||||
'zh-CN': '构建生成式 AI 的\n\u201C操作系统\u201D'
|
||||
@@ -1463,7 +1361,7 @@ const translations = {
|
||||
'careers.hero.body1': {
|
||||
en: "We're building the world's leading visual AI platform \u2014 open, modular, and designed for those who want control, quality and scale in their creative process.",
|
||||
'zh-CN':
|
||||
'我们正在构建全球领先的视觉 AI 平台——开放、模块化,专为追求创作过程中的控制力、品质和规模化的人而设计。'
|
||||
'我们是全球领先的视觉 AI 平台\u2014\u2014一个开放、模块化的系统,任何人都可以精确地构建、定制和自动化 AI 工作流,并拥有完全的控制权。'
|
||||
},
|
||||
'careers.hero.body2': {
|
||||
en: 'From solo creators to enterprise teams, millions of people rely on ComfyUI to push the boundaries of what creative AI can do.',
|
||||
@@ -1504,10 +1402,6 @@ const translations = {
|
||||
|
||||
// CareersRolesSection
|
||||
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
|
||||
'careers.roles.empty': {
|
||||
en: 'No open roles right now. Check back soon.',
|
||||
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
|
||||
},
|
||||
|
||||
// CareersFAQSection
|
||||
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },
|
||||
@@ -2184,13 +2078,6 @@ const translations = {
|
||||
'zh-CN':
|
||||
'从独立艺术家到全球工作室——构建视觉媒体未来的团队都在使用 ComfyUI。'
|
||||
},
|
||||
'customers.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
|
||||
'customers.contact.heading': {
|
||||
en: 'Interested in a case study with ComfyUI? Reach out <a href="https://docs.google.com/forms/d/e/1FAIpQLSd-Keeq1VIePeanQIsdHq9eYeDE82MHJTdvwdgpxCoEzo_CUg/viewform" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">here</a>',
|
||||
'zh-CN':
|
||||
'有兴趣与 ComfyUI 合作案例研究?点击<a href="https://docs.google.com/forms/d/e/1FAIpQLSd-Keeq1VIePeanQIsdHq9eYeDE82MHJTdvwdgpxCoEzo_CUg/viewform" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">此处</a>联系我们'
|
||||
},
|
||||
|
||||
'customers.story.series-entertainment.category': {
|
||||
en: 'GAME & VIDEO PRODUCTION',
|
||||
'zh-CN': '游戏与视频制作'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
ogImage = '/og-default.png',
|
||||
noindex = false,
|
||||
} = Astro.props
|
||||
|
||||
@@ -57,7 +57,7 @@ const websiteJsonLd = {
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={locale} class="overflow-x-clip">
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -6,40 +6,18 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
title="404 — Page Not Found — Comfy"
|
||||
noindex>
|
||||
<div
|
||||
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">
|
||||
<div class="flex shrink-0 flex-col items-center gap-3 text-center">
|
||||
<div class="w-[220px] max-w-[60%]">
|
||||
<svg
|
||||
viewBox="0 0 311 63"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="404 Page Not Found"
|
||||
class="block h-auto w-full">
|
||||
<path d="M11.249 63H29V0C20.8428 0 12.42 6.46924 10.1666 14.4406L0.55317 48.5594C-1.6903 56.5308 3.09184 63 11.249 63Z" fill="#F2FF59"/>
|
||||
<rect width="65" height="63" transform="translate(29)" fill="#F2FF59"/>
|
||||
<path d="M45.2357 16.4576V37.3232H47.6171V41.141H45.2357V47H40.6619V41.141H30.1157V36.9074L40.8887 16.4576H45.2357ZM33.5933 37.3232H40.6619V23.9042L33.5933 37.3232ZM61.5204 47.378C55.6614 47.378 52.7886 43.7492 52.7886 37.2854V26.0966C52.7886 19.6706 55.6614 16.0796 61.5204 16.0796C67.3416 16.0796 70.1766 19.6706 70.1766 26.1344V37.3232C70.1766 43.7492 67.3416 47.378 61.5204 47.378ZM57.4758 37.0964L65.2626 22.8458C64.809 20.7668 63.5238 19.8596 61.5204 19.8596C58.8744 19.8596 57.4758 21.3716 57.4758 25.0004V37.0964ZM57.7782 40.9898C58.3452 42.8042 59.5926 43.598 61.5204 43.598C64.1286 43.598 65.4894 42.086 65.4894 38.4194V26.8526L57.7782 40.9898ZM90.321 16.4576V37.3232H92.7024V41.141H90.321V47H85.7472V41.141H75.201V36.9074L85.974 16.4576H90.321ZM78.6786 37.3232H85.7472V23.9042L78.6786 37.3232Z" fill="#211927"/>
|
||||
<path d="M113.15 48.58L116.26 37.73C116.89 35.52 118.92 34 121.21 34C124.63 34 127.1 37.26 126.17 40.55L125.75 42.06C124.36 46.99 127.32 51 132.37 51H142.85V12.01C137.96 12.27 133.05 16.17 131.7 20.94L130.58 24.9C129.96 27.11 127.95 28.63 125.66 28.63C122.26 28.63 119.8 25.37 120.74 22.1L122.94 14.42C125.22 6.46 120.36 0 112.07 0H94V63C102.28 63 110.85 56.54 113.13 48.58H113.15Z" fill="#F2FF59"/>
|
||||
<rect width="150" height="39" transform="translate(142.85 12)" fill="#F2FF59"/>
|
||||
<path d="M144.516 41V22.0928H149.687C153.665 22.0928 155.21 23.7308 155.21 27.9662V28.5746C155.21 32.7866 153.689 34.5416 149.687 34.5416H147.371V41H144.516ZM147.371 32.2952H149.921C151.653 32.2952 152.285 31.4528 152.285 29.4404V27.1004C152.285 25.1114 151.653 24.3392 149.921 24.3392H147.371V32.2952ZM154.605 41L158.326 22.0928H162.257L165.977 41H163.076L162.467 37.49H158.115L157.53 41H154.605ZM158.536 35.0798H162.07L160.361 25.1114L160.291 24.8072L160.221 25.088L158.536 35.0798ZM171.55 41.234C168.204 41.234 166.332 38.8472 166.332 35.1266V28.0832C166.332 24.2456 168.134 21.8588 171.901 21.8588C175.482 21.8588 177.33 24.0116 177.4 27.5918V29.417H174.499L174.475 26.9132C174.429 24.9476 173.446 24.2222 171.925 24.2222C170.1 24.2222 169.234 25.2518 169.234 27.3812V35.6882C169.234 37.7942 170.053 38.8706 171.878 38.8706C173.586 38.8706 174.405 37.958 174.475 36.0158V33.512H171.527V31.289H177.4V41H175.926L175.575 39.1982C174.733 40.4384 173.446 41.234 171.55 41.234ZM179.103 41V22.0928H187.808V24.503H181.958V30.353H187.48V32.7398H181.958V38.5898H187.808V41H179.103ZM194.026 41V22.0928H196.647L202.099 34.7054V22.0928H204.72V41H202.076L196.647 28.3874V41H194.026ZM211.848 41.234C208.104 41.234 206.279 38.8472 206.279 35.0096V28.0832C206.279 24.2456 208.104 21.8588 211.848 21.8588C215.592 21.8588 217.44 24.2456 217.44 28.0832V35.0096C217.44 38.8472 215.592 41.234 211.848 41.234ZM209.18 35.6882C209.18 37.7942 210.069 38.8706 211.848 38.8706C213.626 38.8706 214.515 37.7942 214.515 35.6882V27.428C214.515 25.322 213.626 24.2222 211.848 24.2222C210.069 24.2222 209.18 25.2986 209.18 27.4046V35.6882ZM221.265 41V24.503H217.544V22.0928H227.864V24.503H224.12V41H221.265ZM233.47 41V22.0928H242.245V24.503H236.324V31.2422H242.011V33.629H236.324V41H233.47ZM248.572 41.234C244.828 41.234 243.003 38.8472 243.003 35.0096V28.0832C243.003 24.2456 244.828 21.8588 248.572 21.8588C252.316 21.8588 254.165 24.2456 254.165 28.0832V35.0096C254.165 38.8472 252.316 41.234 248.572 41.234ZM245.905 35.6882C245.905 37.7942 246.794 38.8706 248.572 38.8706C250.351 38.8706 251.24 37.7942 251.24 35.6882V27.428C251.24 25.322 250.351 24.2222 248.572 24.2222C246.794 24.2222 245.905 25.2986 245.905 27.4046V35.6882ZM260.97 41.234C257.249 41.234 255.634 39.3854 255.634 35.4776V22.0928H258.536V36.0626C258.536 38.2622 259.378 38.8706 260.97 38.8706C262.561 38.8706 263.427 38.2622 263.427 36.0626V22.0928H266.305V35.4776C266.305 39.4088 264.667 41.234 260.97 41.234ZM268.148 41V22.0928H270.769L276.221 34.7054V22.0928H278.842V41H276.198L270.769 28.3874V41H268.148ZM280.728 41V22.0928H285.619C289.62 22.0928 291.492 23.4968 291.492 28.0598V35.033C291.492 39.5492 289.62 41 285.619 41H280.728ZM283.583 38.7536H285.642C287.772 38.7536 288.567 38.0048 288.567 35.4542V27.6386C288.567 25.088 287.772 24.3392 285.619 24.3392H283.583V38.7536Z" fill="#211927"/>
|
||||
<path d="M303.839 12H292.85V51C297.9 51 303.114 46.9952 304.509 42.0606L310.46 20.9394C311.849 16.0048 308.888 12 303.839 12Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[15px] tracking-[0.02em] opacity-90">
|
||||
Looks like this node went off-grid.
|
||||
</p>
|
||||
<p
|
||||
class="font-mono text-sm tracking-[0.05em] opacity-75"
|
||||
>Score: <span id="scoreNow">0</span><span
|
||||
class="mx-2.5 opacity-50">|</span
|
||||
>Best: <span id="scoreBest">0</span></p
|
||||
>
|
||||
</div>
|
||||
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
|
||||
class="grid w-full grid-rows-1 place-items-center p-4 h-[calc(100dvh-12rem)]">
|
||||
<div
|
||||
class="relative flex size-full min-h-0 items-center justify-center overflow-hidden">
|
||||
<svg
|
||||
id="game"
|
||||
class="block size-full"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<use
|
||||
class="pointer-events-none text-secondary-mauve"
|
||||
href="/svg/404-title.svg#title404"
|
||||
></use>
|
||||
<g id="board"></g>
|
||||
<g
|
||||
fill="currentColor"
|
||||
@@ -58,6 +36,19 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
class="cursor-pointer uppercase"
|
||||
>CLICK TO START</text
|
||||
>
|
||||
<text
|
||||
id="scoreLine"
|
||||
x="300"
|
||||
y="232"
|
||||
font-family="'PP Formula', sans-serif"
|
||||
font-size="20"
|
||||
letter-spacing="0.12em"
|
||||
text-anchor="end"
|
||||
dominant-baseline="middle"
|
||||
transform="rotate(-31 400 200)"
|
||||
display="none"
|
||||
class="uppercase"
|
||||
></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/about/HeroSection.vue'
|
||||
import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
@@ -9,7 +8,6 @@ import CareersSection from '../components/about/CareersSection.vue'
|
||||
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
<ValuesSection client:visible />
|
||||
<CareersSection />
|
||||
|
||||
@@ -5,20 +5,6 @@ import RolesSection from '../components/careers/RolesSection.vue'
|
||||
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
|
||||
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -26,7 +12,7 @@ const departments = outcome.snapshot.departments
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
<RolesSection client:visible />
|
||||
<WhyJoinSection client:visible />
|
||||
<TeamPhotosSection client:visible />
|
||||
<FAQSection
|
||||
|
||||
@@ -4,7 +4,6 @@ import HeroSection from '../components/customers/HeroSection.vue'
|
||||
import StorySection from '../components/customers/StorySection.vue'
|
||||
import FeedbackSection from '../components/customers/FeedbackSection.vue'
|
||||
import VideoSection from '../components/customers/VideoSection.vue'
|
||||
import ContactSection from '../components/customers/ContactSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Customer Stories — Comfy">
|
||||
@@ -12,5 +11,4 @@ import ContactSection from '../components/customers/ContactSection.vue'
|
||||
<StorySection />
|
||||
<FeedbackSection client:load />
|
||||
<VideoSection client:load />
|
||||
<ContactSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/about/HeroSection.vue'
|
||||
import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
@@ -9,7 +8,6 @@ import CareersSection from '../../components/about/CareersSection.vue'
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
<ValuesSection locale="zh-CN" client:visible />
|
||||
<CareersSection locale="zh-CN" />
|
||||
|
||||
@@ -5,20 +5,6 @@ import RolesSection from '../../components/careers/RolesSection.vue'
|
||||
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
|
||||
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -26,7 +12,7 @@ const departments = outcome.snapshot.departments
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
<RolesSection locale="zh-CN" client:visible />
|
||||
<WhyJoinSection locale="zh-CN" client:visible />
|
||||
<TeamPhotosSection client:visible />
|
||||
<FAQSection
|
||||
|
||||
@@ -5,20 +5,12 @@ import HeroSection from '../../../components/product/enterprise/HeroSection.vue'
|
||||
import SocialProofBarSection from '../../../components/common/SocialProofBarSection.vue'
|
||||
import ReasonSection from '../../../components/product/enterprise/ReasonSection.vue'
|
||||
import TeamSection from '../../../components/product/enterprise/TeamSection.vue'
|
||||
import DataOwnershipSection from '../../../components/product/enterprise/DataOwnershipSection.vue'
|
||||
import BYOKeySection from '../../../components/product/enterprise/BYOKeySection.vue'
|
||||
import OrchestrationSection from '../../../components/product/enterprise/OrchestrationSection.vue'
|
||||
import ProductCardsSection from '../../../components/common/ProductCardsSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy 企业版">
|
||||
<BaseLayout title="Comfy Enterprise">
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
<TeamSection locale="zh-CN" />
|
||||
<DataOwnershipSection locale="zh-CN" client:visible />
|
||||
<BYOKeySection locale="zh-CN" />
|
||||
<OrchestrationSection locale="zh-CN" client:visible />
|
||||
<ProductCardsSection locale="zh-CN" exclude-product="enterprise" label-key="products.labelProducts" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,7 +4,6 @@ import HeroSection from '../../components/customers/HeroSection.vue'
|
||||
import StorySection from '../../components/customers/StorySection.vue'
|
||||
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
|
||||
import VideoSection from '../../components/customers/VideoSection.vue'
|
||||
import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="客户故事 — Comfy">
|
||||
@@ -12,5 +11,4 @@ import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
<StorySection locale="zh-CN" />
|
||||
<FeedbackSection locale="zh-CN" client:load />
|
||||
<VideoSection locale="zh-CN" client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/gallery/HeroSection.vue'
|
||||
import GallerySection from '../../components/gallery/GallerySection.vue'
|
||||
import ContactSection from '../../components/gallery/ContactSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="作品集 — Comfy">
|
||||
<HeroSection locale="zh-CN" />
|
||||
<GallerySection locale="zh-CN" client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
<div class="bg-black text-white">
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
|
||||
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
在 ComfyUI 中<span class="text-brand-yellow">构建、调整与梦想</span>
|
||||
</h1>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-lg text-smoke-700">
|
||||
社区使用 ComfyUI 创作的精彩作品一瞥。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Placeholder Grid -->
|
||||
<section class="mx-auto max-w-6xl px-6 pb-24">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map(() => (
|
||||
<div class="flex aspect-video items-center justify-center rounded-xl border border-white/10 bg-charcoal-600">
|
||||
<p class="text-sm text-smoke-700">社区展示即将上线</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="mx-auto max-w-3xl px-6 pb-32 text-center">
|
||||
<h2 class="text-2xl font-semibold">有很酷的作品想分享?</h2>
|
||||
<a
|
||||
href="https://support.comfy.org/"
|
||||
class="mt-6 inline-block rounded-full bg-brand-yellow px-8 py-3 font-medium text-black transition hover:opacity-90"
|
||||
>
|
||||
联系我们
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -62,15 +62,10 @@ function requireElement<T extends Element>(
|
||||
const isSVGSVG = (el: Element): el is SVGSVGElement =>
|
||||
el instanceof SVGSVGElement
|
||||
const isSVGG = (el: Element): el is SVGGElement => el instanceof SVGGElement
|
||||
function isSVGText(el: Element): el is SVGTextElement {
|
||||
return el instanceof SVGTextElement
|
||||
}
|
||||
function isHTMLDiv(el: Element): el is HTMLDivElement {
|
||||
return el instanceof HTMLDivElement
|
||||
}
|
||||
function isHTMLSpan(el: Element): el is HTMLSpanElement {
|
||||
return el instanceof HTMLSpanElement
|
||||
}
|
||||
const isSVGText = (el: Element): el is SVGTextElement =>
|
||||
el instanceof SVGTextElement
|
||||
const isHTMLDiv = (el: Element): el is HTMLDivElement =>
|
||||
el instanceof HTMLDivElement
|
||||
|
||||
function setOverlayVisible(el: HTMLElement, visible: boolean) {
|
||||
el.classList.toggle('hidden', !visible)
|
||||
@@ -375,8 +370,7 @@ const board = requireElement('board', isSVGG)
|
||||
const pauseOverlay = requireElement('pauseOverlay', isHTMLDiv)
|
||||
const svg = requireElement('game', isSVGSVG)
|
||||
const startCta = requireElement('startCta', isSVGText)
|
||||
const scoreNowEl = requireElement('scoreNow', isHTMLSpan)
|
||||
const scoreBestEl = requireElement('scoreBest', isHTMLSpan)
|
||||
const scoreLine = requireElement('scoreLine', isSVGText)
|
||||
|
||||
let best = loadBest()
|
||||
let waitingToStart = true
|
||||
@@ -406,11 +400,6 @@ function saveBest(v: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreNowEl.textContent = String(score)
|
||||
scoreBestEl.textContent = String(best)
|
||||
}
|
||||
|
||||
const cellsEqual = (a: Cell, b: Cell) => a.i === b.i && a.j === b.j
|
||||
const inBounds = (c: Cell) => c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
|
||||
|
||||
@@ -436,7 +425,6 @@ function reset() {
|
||||
}
|
||||
rebirthStart = performance.now()
|
||||
placeFood()
|
||||
updateScoreDisplay()
|
||||
render()
|
||||
ensureAnimationLoop()
|
||||
}
|
||||
@@ -479,7 +467,6 @@ function step() {
|
||||
best = score
|
||||
saveBest(best)
|
||||
}
|
||||
updateScoreDisplay()
|
||||
placeFood()
|
||||
onEat()
|
||||
} else {
|
||||
@@ -497,10 +484,14 @@ function gameOver() {
|
||||
window.clearInterval(tickHandle)
|
||||
tickHandle = null
|
||||
}
|
||||
const finalScore = score
|
||||
const finalBest = best
|
||||
triggerExplosion()
|
||||
restartTimeout = window.setTimeout(() => {
|
||||
waitingToStart = true
|
||||
startCta.removeAttribute('display')
|
||||
scoreLine.textContent = `Last Score: ${finalScore} | Best: ${finalBest}`
|
||||
scoreLine.removeAttribute('display')
|
||||
reset()
|
||||
}, EXPLODE_DURATION_MS + 300)
|
||||
}
|
||||
@@ -554,11 +545,6 @@ function render() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!alive) {
|
||||
board.innerHTML = parts.join('')
|
||||
return
|
||||
}
|
||||
|
||||
const actors = [
|
||||
...snake.map((s, idx) => ({
|
||||
...s,
|
||||
@@ -692,6 +678,7 @@ function startGame() {
|
||||
}
|
||||
waitingToStart = false
|
||||
startCta.setAttribute('display', 'none')
|
||||
scoreLine.setAttribute('display', 'none')
|
||||
if (tickHandle !== null) window.clearInterval(tickHandle)
|
||||
tickHandle = window.setInterval(step, TICK_MS)
|
||||
}
|
||||
|
||||
@@ -75,25 +75,14 @@
|
||||
--aspect-ratio-gallery-card: 47/31;
|
||||
}
|
||||
|
||||
@property --border-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
@keyframes border-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-border-spin {
|
||||
animation: border-angle-spin 2s linear infinite;
|
||||
background: conic-gradient(
|
||||
from var(--border-angle),
|
||||
color-mix(in srgb, var(--color-primary-comfy-yellow) 4%, transparent) 0%,
|
||||
var(--color-primary-comfy-yellow) 100%
|
||||
);
|
||||
animation: border-spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
@@ -191,14 +180,6 @@
|
||||
top: 0.19em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
(e.g. Low Power Mode). These are decorative background videos. */
|
||||
video::-webkit-media-controls-start-playback-button,
|
||||
video::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--site-bg: var(--color-primary-comfy-ink);
|
||||
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
import type { RolesSnapshot } from '../data/roles'
|
||||
|
||||
import { reportAshbyOutcome, resetAshbyReporterForTests } from './ashby.ci'
|
||||
|
||||
function baseSnapshot(): RolesSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
id: 'x',
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
droppedCount,
|
||||
droppedRoles:
|
||||
droppedCount === 0
|
||||
? []
|
||||
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
id: 'x',
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportAshbyOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetAshbyReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'ashby-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits nothing on a clean fresh outcome', () => {
|
||||
reportAshbyOutcome(freshOutcome(0))
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one set of annotations across repeated calls', () => {
|
||||
reportAshbyOutcome(freshOutcome(1))
|
||||
reportAshbyOutcome(freshOutcome(1))
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=Ashby: dropped 1 invalid')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
|
||||
})
|
||||
|
||||
it('emits ::error for auth failures in a stale outcome', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 401 Unauthorized',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=Ashby authentication failed')
|
||||
})
|
||||
|
||||
it('emits ::warning for missing-env stale outcomes', () => {
|
||||
reportAshbyOutcome({
|
||||
status: 'stale',
|
||||
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=Ashby integration')
|
||||
})
|
||||
|
||||
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
|
||||
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=Ashby fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
|
||||
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
|
||||
delete process.env.GITHUB_STEP_SUMMARY
|
||||
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
|
||||
let hasReported = false
|
||||
|
||||
export function resetAshbyReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
export function reportAshbyOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
|
||||
const drops = outcome.droppedRoles
|
||||
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
|
||||
]
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [staleAnnotation(outcome.reason)]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=Ashby fetch failed and no snapshot is available::Cannot build careers page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website ashby:refresh-snapshot\` locally with a valid WEBSITE_ASHBY_API_KEY.%0A 2. Commit apps/website/src/data/ashby-roles.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function staleAnnotation(reason: string): string {
|
||||
const escaped = escapeAnnotation(reason)
|
||||
if (reason.startsWith('missing ')) {
|
||||
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
}
|
||||
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
|
||||
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
}
|
||||
if (reason.startsWith('envelope')) {
|
||||
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
|
||||
}
|
||||
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## 💼 Careers (Ashby)\n'
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', '✅ Fresh (fetched from Ashby)'])
|
||||
rows.push([
|
||||
'Roles',
|
||||
String(
|
||||
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
|
||||
)
|
||||
])
|
||||
rows.push(['Dropped', String(outcome.droppedCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', '⚠️ Stale (using snapshot — Ashby fetch failed)'])
|
||||
rows.push([
|
||||
'Roles',
|
||||
String(
|
||||
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
|
||||
)
|
||||
])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', '❌ Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AshbyJobPostingSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
department: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
isListed: z.boolean(),
|
||||
jobUrl: z.string().url(),
|
||||
applyUrl: z.string().url().optional()
|
||||
})
|
||||
|
||||
export const AshbyJobBoardResponseSchema = z.object({
|
||||
apiVersion: z.literal('1'),
|
||||
jobs: z.array(z.unknown())
|
||||
})
|
||||
|
||||
export type AshbyJobPosting = z.infer<typeof AshbyJobPostingSchema>
|
||||
@@ -1,328 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AshbyJobPosting } from './ashby.schema'
|
||||
import type { RolesSnapshot } from '../data/roles'
|
||||
|
||||
import { fetchRolesForBuild, resetAshbyFetcherForTests } from './ashby'
|
||||
|
||||
const BASE_URL = 'https://ashby.test'
|
||||
const BOARD = 'comfy-org'
|
||||
const KEY = 'abc-123-secret'
|
||||
|
||||
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
|
||||
return {
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
isListed: true,
|
||||
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer/apply',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
const base: ResponseInit = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
return new Response(JSON.stringify(body), { ...base, ...init })
|
||||
}
|
||||
|
||||
function makeSnapshot(roleCount = 2): RolesSnapshot {
|
||||
const roles = Array.from({ length: roleCount }, (_, i) => ({
|
||||
id: `snapshot-role-${i}`,
|
||||
title: `Snapshot Role ${i}`,
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
|
||||
}))
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
departments: [{ name: 'ENGINEERING', key: 'engineering', roles }]
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
|
||||
const file = join(dir, 'ashby-roles.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchRolesForBuild', () => {
|
||||
const savedApiKey = process.env.WEBSITE_ASHBY_API_KEY
|
||||
const savedBoardName = process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
|
||||
beforeEach(() => {
|
||||
resetAshbyFetcherForTests()
|
||||
delete process.env.WEBSITE_ASHBY_API_KEY
|
||||
delete process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
|
||||
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
|
||||
})
|
||||
|
||||
it('returns fresh when the API succeeds', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.departments).toHaveLength(1)
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
|
||||
/design-engineer\/apply$/
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).applyUrl
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
|
||||
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops invalid roles individually and keeps the valid ones', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
|
||||
})
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(1)
|
||||
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
|
||||
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments).toEqual([])
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('normalizes missing department and location to safe defaults', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).department
|
||||
delete (job as Record<string, unknown>).location
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
const [department] = outcome.snapshot.departments
|
||||
expect(department?.name).toBe('OTHER')
|
||||
expect(department?.roles[0]?.location).toBe('Remote')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('filters out roles with isListed=false', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
apiVersion: '1',
|
||||
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
|
||||
})
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
const titles = outcome.snapshot.departments.flatMap((d) =>
|
||||
d.roles.map((r) => r.title)
|
||||
)
|
||||
expect(titles).not.toContain('Hidden')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns stale with missing env when the snapshot is present', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const fetchImpl = vi.fn()
|
||||
const outcome = await fetchRolesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^missing /)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when both env and snapshot are missing', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on HTTP 401', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 401/)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
sleep,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
||||
expect(sleep).toHaveBeenCalledTimes(3)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to snapshot on envelope schema mismatch', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^envelope schema/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('memoizes within a single process', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
const opts = {
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
}
|
||||
const [a, b] = await Promise.all([
|
||||
fetchRolesForBuild(opts),
|
||||
fetchRolesForBuild(opts)
|
||||
])
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never writes to the snapshot file on success', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const before = new URL(snapshotUrl.href)
|
||||
const fs = await import('node:fs')
|
||||
const initial = fs.readFileSync(before).toString()
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [validJob()] })
|
||||
)
|
||||
await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
const after = fs.readFileSync(before).toString()
|
||||
expect(after).toBe(initial)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on 4xx auth failures for 403', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
|
||||
await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -1,299 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { AshbyJobPosting } from './ashby.schema'
|
||||
import type { Department, Role, RolesSnapshot } from '../data/roles'
|
||||
|
||||
import bundledSnapshot from '../data/ashby-roles.snapshot.json' with { type: 'json' }
|
||||
import {
|
||||
AshbyJobBoardResponseSchema,
|
||||
AshbyJobPostingSchema
|
||||
} from './ashby.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: RolesSnapshot
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
interface FetchRolesOptions {
|
||||
apiKey?: string
|
||||
boardName?: string
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
retryDelaysMs?: readonly number[]
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
|
||||
export function resetAshbyFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
}
|
||||
|
||||
export function fetchRolesForBuild(
|
||||
options: FetchRolesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
inflight ??= doFetchRolesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchRolesForBuild(
|
||||
options: FetchRolesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
|
||||
const boardName =
|
||||
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
|
||||
if (!apiKey || !boardName) {
|
||||
return fallback(
|
||||
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
options.snapshotUrl
|
||||
)
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, boardName, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: result.departments
|
||||
},
|
||||
droppedCount: result.droppedRoles.length,
|
||||
droppedRoles: result.droppedRoles
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
kind: 'ok'
|
||||
departments: Department[]
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
|
||||
interface FetchErr {
|
||||
kind: 'err'
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function tryFetchAndParse(
|
||||
apiKey: string,
|
||||
boardName: string,
|
||||
options: FetchRolesOptions
|
||||
): Promise<FetchOk | FetchErr> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
|
||||
const url = `${baseUrl}/posting-api/job-board/${encodeURIComponent(
|
||||
boardName
|
||||
)}?includeCompensation=false`
|
||||
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
|
||||
|
||||
let lastReason = 'unknown error'
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
||||
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
|
||||
|
||||
const response = await callOnce(fetchImpl, url, authHeader, timeoutMs)
|
||||
if (response.kind === 'err') {
|
||||
lastReason = response.reason
|
||||
if (!response.retryable) return response
|
||||
continue
|
||||
}
|
||||
|
||||
const envelope = AshbyJobBoardResponseSchema.safeParse(response.body)
|
||||
if (!envelope.success) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `envelope schema validation failed: ${envelope.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return parseRoles(envelope.data.jobs)
|
||||
}
|
||||
|
||||
return { kind: 'err', reason: lastReason }
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string; retryable: boolean }
|
||||
|
||||
async function callOnce(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
authHeader: string,
|
||||
timeoutMs: number
|
||||
): Promise<CallResponse> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json; version=1'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
if (res.ok) {
|
||||
return { kind: 'ok', body: await res.json() }
|
||||
}
|
||||
const retryable =
|
||||
res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||
retryable
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason, retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoles(jobs: readonly unknown[]): FetchOk {
|
||||
const valid: AshbyJobPosting[] = []
|
||||
const droppedRoles: DroppedRole[] = []
|
||||
|
||||
for (const raw of jobs) {
|
||||
const parsed = AshbyJobPostingSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
droppedRoles.push({
|
||||
title: extractTitle(raw),
|
||||
reason: parsed.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!parsed.data.isListed) continue
|
||||
valid.push(parsed.data)
|
||||
}
|
||||
|
||||
return { kind: 'ok', departments: groupByDepartment(valid), droppedRoles }
|
||||
}
|
||||
|
||||
function extractTitle(raw: unknown): string {
|
||||
if (
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
'title' in raw &&
|
||||
typeof (raw as { title: unknown }).title === 'string'
|
||||
) {
|
||||
return (raw as { title: string }).title
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const DEFAULT_DEPARTMENT = 'Other'
|
||||
const DEFAULT_LOCATION = 'Remote'
|
||||
|
||||
function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
|
||||
const byKey = new Map<string, Department>()
|
||||
for (const job of jobs) {
|
||||
const displayDepartment = normalizeDepartment(job.department)
|
||||
const name = displayDepartment.toUpperCase()
|
||||
const key = slugify(name)
|
||||
const existing = byKey.get(key)
|
||||
const role = toDomainRole(job, displayDepartment)
|
||||
if (existing) {
|
||||
existing.roles.push(role)
|
||||
} else {
|
||||
byKey.set(key, { name, key, roles: [role] })
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function toDomainRole(job: AshbyJobPosting, department: string): Role {
|
||||
const applyUrl = job.applyUrl ?? job.jobUrl
|
||||
return {
|
||||
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
|
||||
title: job.title,
|
||||
department: capitalize(department),
|
||||
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
|
||||
applyUrl
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepartment(raw: string | undefined): string {
|
||||
const trimmed = (raw ?? '').trim()
|
||||
return trimmed.length > 0 ? trimmed : DEFAULT_DEPARTMENT
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<RolesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isRolesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isRolesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isRolesSnapshot(value: unknown): value is RolesSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
|
||||
return (
|
||||
typeof candidate.fetchedAt === 'string' &&
|
||||
Array.isArray(candidate.departments)
|
||||
)
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -8,10 +8,8 @@
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"e2e/**/*",
|
||||
"scripts/**/*",
|
||||
"astro.config.ts",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts"
|
||||
"playwright.config.ts"
|
||||
],
|
||||
"exclude": ["src/**/*.stories.ts"],
|
||||
"references": [{ "path": "./tsconfig.stories.json" }]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
globals: false
|
||||
}
|
||||
})
|
||||
@@ -15,15 +15,11 @@ browser_tests/
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions)
|
||||
│ ├── components/ - Page object classes (locators, user interactions)
|
||||
│ │ ├── Actionbar.ts
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── ManageGroupNode.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ ├── Templates.ts
|
||||
│ │ ├── Topbar.ts
|
||||
│ │ └── ...
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
@@ -32,36 +28,17 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
|
||||
│ ├── builderTestUtils.ts
|
||||
│ ├── clipboardSpy.ts
|
||||
│ ├── fitToView.ts
|
||||
│ ├── perfReporter.ts
|
||||
│ └── ...
|
||||
│ └── utils/ - Pure utility functions (no page dependency)
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
|
||||
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
|
||||
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
|
||||
|
||||
### Placement Rule
|
||||
|
||||
When adding a new file, use this decision tree:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[New file in browser_tests/fixtures/] --> B{Has any code?}
|
||||
B -- No, JSON/data only --> D[fixtures/data/]
|
||||
B -- Yes --> C{Is it a class?}
|
||||
C -- No, exported functions --> U[fixtures/utils/]
|
||||
C -- Yes --> E{Owns locators for a<br/>specific UI region?}
|
||||
E -- Yes --> P[fixtures/components/]
|
||||
E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/]
|
||||
```
|
||||
- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area.
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||
|
||||
## Page Object Locator Style
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ DISABLE_VUE_PLUGINS=true
|
||||
# Test against dev server (recommended) or backend directly
|
||||
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
|
||||
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
|
||||
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
|
||||
|
||||
# Path to ComfyUI for backing up user data/settings before tests
|
||||
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
@@ -140,9 +139,12 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
|
||||
- **ComfyMouse**: Helper for precise mouse operations on the canvas
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`)
|
||||
- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`)
|
||||
- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`)
|
||||
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
|
||||
- `actionbar.ts`: Interact with the action bar
|
||||
- `manageGroupNode.ts`: Group node management operations
|
||||
- `templates.ts`: Template workflows operations
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
|
||||
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [450, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports'
|
||||
|
||||
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
@@ -22,7 +22,6 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
|
||||
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
ModelLibrarySidebarTab,
|
||||
@@ -55,13 +54,11 @@ class ComfyPropertiesPanel {
|
||||
readonly root: Locator
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +137,6 @@ class ComfyMenu {
|
||||
|
||||
export class ComfyPage {
|
||||
public readonly url: string
|
||||
public readonly apiUrl: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly selectionToolbox: Locator
|
||||
@@ -163,7 +159,6 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly templatesDialog: TemplatesDialog
|
||||
public readonly titleEditor: TitleEditor
|
||||
public readonly mediaLightbox: MediaLightbox
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly appMode: AppModeHelper
|
||||
@@ -200,7 +195,6 @@ export class ComfyPage {
|
||||
public readonly request: APIRequestContext
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
@@ -210,14 +204,13 @@ export class ComfyPage {
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.templatesDialog = new TemplatesDialog(page)
|
||||
this.titleEditor = new TitleEditor(page)
|
||||
this.mediaLightbox = new MediaLightbox(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.appMode = new AppModeHelper(this)
|
||||
@@ -243,7 +236,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.apiUrl}/api/users`)
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
@@ -257,7 +250,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
@@ -269,7 +262,7 @@ export class ComfyPage {
|
||||
|
||||
async setupSettings(settings: Record<string, unknown>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.apiUrl}/api/devtools/set_settings`,
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
|
||||
@@ -1,104 +1,29 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
readonly filterSearch: Locator
|
||||
readonly results: Locator
|
||||
readonly filterOptions: Locator
|
||||
readonly filterChips: Locator
|
||||
readonly noResults: Locator
|
||||
readonly nodeIdBadge: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
constructor(readonly page: Page) {
|
||||
this.dialog = page.getByRole('search')
|
||||
this.input = this.dialog.getByRole('combobox')
|
||||
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
|
||||
this.results = this.dialog.getByTestId(searchBoxV2.resultItem)
|
||||
this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption)
|
||||
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
|
||||
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
|
||||
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
categoryButton(categoryId: string): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
|
||||
return this.dialog.getByTestId(`category-${categoryId}`)
|
||||
}
|
||||
|
||||
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
|
||||
rootCategoryButton(id: RootCategoryId): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
|
||||
filterBarButton(name: string): Locator {
|
||||
return this.dialog.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** Top filter-bar input/output type popover trigger. */
|
||||
typeFilterButton(key: 'input' | 'output'): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.typeFilter(key))
|
||||
}
|
||||
|
||||
async applyTypeFilter(
|
||||
key: 'input' | 'output',
|
||||
typeName: string
|
||||
): Promise<void> {
|
||||
const trigger = this.typeFilterButton(key)
|
||||
await trigger.click()
|
||||
await this.filterOptions.first().waitFor({ state: 'visible' })
|
||||
await this.filterSearch.fill(typeName)
|
||||
await this.filterOptions.filter({ hasText: typeName }).first().click()
|
||||
// The popover does not auto-close on selection — toggle the trigger.
|
||||
await trigger.click()
|
||||
await this.filterOptions.first().waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async removeFilterChip(index = 0): Promise<void> {
|
||||
await this.filterChips
|
||||
.nth(index)
|
||||
.getByTestId(searchBoxV2.chipDelete)
|
||||
.click()
|
||||
}
|
||||
|
||||
async toggle(): Promise<void> {
|
||||
await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle')
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (await this.input.isVisible()) return
|
||||
await this.toggle()
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async openByDoubleClickCanvas(): Promise<void> {
|
||||
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
|
||||
// does not intercept; coords target a viewport spot that is on the canvas
|
||||
// and clear of both the side toolbar and any default-graph nodes.
|
||||
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
}
|
||||
|
||||
async ensureV2Search(): Promise<void> {
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
}
|
||||
|
||||
async setup(): Promise<void> {
|
||||
await this.ensureV2Search()
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.Action',
|
||||
'search box'
|
||||
)
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'search box'
|
||||
)
|
||||
async reload(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import type { Locator, Page } from '@playwright/test'
|
||||
export class ContextMenu {
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
}
|
||||
|
||||
@@ -22,16 +20,8 @@ export class ContextMenu {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a litegraph menu entry. Selects the most recently opened matching
|
||||
* entry so nested submenu items can be reached without being shadowed by
|
||||
* the parent menu still visible behind them.
|
||||
*/
|
||||
async clickLitegraphMenuItem(name: string): Promise<void> {
|
||||
await this.page
|
||||
.locator('.litemenu-entry:visible', { hasText: name })
|
||||
.last()
|
||||
.click()
|
||||
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
@@ -41,10 +31,7 @@ export class ContextMenu {
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphContextVisible = await this.litegraphContextMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible || litegraphContextVisible
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -76,8 +63,7 @@ export class ContextMenu {
|
||||
async waitForHidden(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.primeVueMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphContextMenu.waitFor({ state: 'hidden' })
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class NodeTemplatesManageDialog extends BaseDialog {
|
||||
public readonly rows: Locator
|
||||
/**
|
||||
* Hidden `<input type="file">` used by the Import button. Attached to
|
||||
* `document.body` (not inside the dialog) by the legacy extension, so it
|
||||
* cannot be scoped via `this.root`.
|
||||
*/
|
||||
public readonly importInput: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.nodeTemplates.manageDialog)
|
||||
this.rows = this.root.locator('.templateManagerRow')
|
||||
this.importInput = page.locator('input[type="file"][accept=".json"]')
|
||||
}
|
||||
|
||||
rowByName(name: string): Locator {
|
||||
const escaped = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return this.rows.filter({
|
||||
has: this.page.locator(`input[data-name="${escaped}"]`)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class PublishDialog extends BaseDialog {
|
||||
readonly nav: Locator
|
||||
readonly footer: Locator
|
||||
readonly savePrompt: Locator
|
||||
readonly describeStep: Locator
|
||||
readonly finishStep: Locator
|
||||
readonly profilePrompt: Locator
|
||||
readonly gateFlow: Locator
|
||||
readonly nameInput: Locator
|
||||
readonly descriptionTextarea: Locator
|
||||
readonly tagsInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly nextButton: Locator
|
||||
readonly publishButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, TestIds.publish.dialog)
|
||||
this.nav = this.root.getByTestId(TestIds.publish.nav)
|
||||
this.footer = this.root.getByTestId(TestIds.publish.footer)
|
||||
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
|
||||
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
|
||||
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
|
||||
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
|
||||
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
|
||||
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
|
||||
this.descriptionTextarea = this.describeStep.locator('textarea')
|
||||
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
|
||||
this.backButton = this.footer.getByRole('button', { name: 'Back' })
|
||||
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
|
||||
this.publishButton = this.footer.getByRole('button', {
|
||||
name: 'Publish to ComfyHub'
|
||||
})
|
||||
}
|
||||
|
||||
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
|
||||
// dev and production, rather than clicking through the UI.
|
||||
async open(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.dialog.showPublishDialog()
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
tagSuggestion(name: string): Locator {
|
||||
return this.describeStep.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
navStep(label: string): Locator {
|
||||
return this.nav.getByRole('button', { name: label })
|
||||
}
|
||||
|
||||
currentNavStep(): Locator {
|
||||
return this.nav.locator('[aria-current="step"]')
|
||||
}
|
||||
|
||||
async goNext(): Promise<void> {
|
||||
await this.nextButton.click()
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
await this.backButton.click()
|
||||
}
|
||||
|
||||
async goToStep(label: string): Promise<void> {
|
||||
await this.navStep(label).click()
|
||||
}
|
||||
}
|
||||
@@ -250,26 +250,6 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
|
||||
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
|
||||
|
||||
function getMediaFilterLabel(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): MediaFilterLabel {
|
||||
switch (filter) {
|
||||
case 'image':
|
||||
return 'Image'
|
||||
case 'video':
|
||||
return 'Video'
|
||||
case 'audio':
|
||||
return 'Audio'
|
||||
case '3d':
|
||||
return '3D'
|
||||
default:
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Tab navigation ---
|
||||
public readonly generatedTab: Locator
|
||||
@@ -281,13 +261,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
public readonly filterButton: Locator
|
||||
|
||||
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
|
||||
public readonly filterImageCheckbox: Locator
|
||||
public readonly filterVideoCheckbox: Locator
|
||||
public readonly filterAudioCheckbox: Locator
|
||||
public readonly filter3DCheckbox: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
@@ -296,8 +269,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
public readonly sortLongestFirst: Locator
|
||||
public readonly sortFastestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
@@ -328,17 +299,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
)
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.filterButton = page.getByRole('button', { name: 'Filter by' })
|
||||
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
|
||||
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
|
||||
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
|
||||
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.sortLongestFirst = page.getByText('Generation time (longest first)')
|
||||
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
|
||||
this.assetCards = page
|
||||
.getByRole('button')
|
||||
.and(page.locator('[data-selected]'))
|
||||
@@ -370,12 +334,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
|
||||
return this.page.getByRole('checkbox', {
|
||||
name: getMediaFilterLabel(filter)
|
||||
})
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
@@ -425,29 +383,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async openFilterMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.filterButton.click()
|
||||
await this.filterCheckbox('Image').waitFor({
|
||||
state: 'visible',
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async toggleMediaTypeFilter(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): Promise<void> {
|
||||
const checkbox = this.filterCheckbox(filter)
|
||||
const before = await checkbox.getAttribute('aria-checked')
|
||||
await checkbox.click()
|
||||
const expected = before === 'true' ? 'false' : 'true'
|
||||
await expect(checkbox).toHaveAttribute('aria-checked', expected)
|
||||
}
|
||||
|
||||
async getAssetCardOrder(): Promise<string[]> {
|
||||
return await this.assetCards.allInnerTexts()
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
|
||||
@@ -10,8 +10,6 @@ export class SubgraphBreadcrumbPanel {
|
||||
readonly activeItem: Locator
|
||||
readonly missingNodesIcon: Locator
|
||||
readonly blueprintTag: Locator
|
||||
readonly rootItem: Locator
|
||||
readonly rootBlueprintTag: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
@@ -25,10 +23,10 @@ export class SubgraphBreadcrumbPanel {
|
||||
TestIds.breadcrumb.missingNodesIcon
|
||||
)
|
||||
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
|
||||
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
|
||||
this.rootBlueprintTag = this.rootItem.getByTestId(
|
||||
TestIds.breadcrumb.blueprintTag
|
||||
)
|
||||
}
|
||||
|
||||
rootItem(): Locator {
|
||||
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
|
||||
}
|
||||
|
||||
subgraphItem(subgraphId: string): Locator {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* The node/group title-editing input. Rendered in three scopes: the canvas
|
||||
* overlay (page-wide), the properties panel, and the Vue node itself.
|
||||
*/
|
||||
export class TitleEditor {
|
||||
public readonly input: Locator
|
||||
|
||||
constructor(scope: Page | Locator) {
|
||||
this.input = scope.getByTestId(TestIds.node.titleInput)
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await this.input.fill(title)
|
||||
await this.input.press('Enter')
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
await this.input.press('Escape')
|
||||
}
|
||||
|
||||
async expectVisible(): Promise<void> {
|
||||
await expect(this.input).toBeVisible()
|
||||
}
|
||||
|
||||
async expectHidden(): Promise<void> {
|
||||
await expect(this.input).toBeHidden()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Position } from '@e2e/fixtures/constants/types'
|
||||
|
||||
/**
|
||||
* Hardcoded positions for the default graph loaded in tests with the legacy menu.
|
||||
* Hardcoded positions for the default graph loaded in tests.
|
||||
* These coordinates are specific to the default workflow viewport.
|
||||
*/
|
||||
export const DefaultGraphPositions = {
|
||||
@@ -51,13 +51,3 @@ export const DefaultGraphPositions = {
|
||||
loadCheckpoint: { pos: Position; size: { width: number; height: number } }
|
||||
emptyLatent: { pos: Position; size: { width: number; height: number } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded positions for the default graph loaded in tests with the new default menu.
|
||||
* These coordinates are specific to the default workflow viewport.
|
||||
*/
|
||||
export const DefaultGraphNewMenuPositions = {
|
||||
emptyCanvasClick: { x: 200, y: 200 }
|
||||
} as const satisfies {
|
||||
emptyCanvasClick: Position
|
||||
}
|
||||
|
||||
@@ -1,72 +1,32 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportResponse,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = '**/api/jobs?*'
|
||||
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetExportRoutePattern = '**/api/assets/export'
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/**
|
||||
* Media kinds supported by the assets sidebar filter UI. The string values
|
||||
* match what the backend stores on `preview_output.mediaType` (`images` is
|
||||
* intentionally plural to match existing API conventions; the others are
|
||||
* singular as emitted by `useMediaAssetGalleryStore`).
|
||||
*
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
video: 'mp4',
|
||||
audio: 'wav',
|
||||
'3D': 'glb'
|
||||
}
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & {
|
||||
id: string
|
||||
/**
|
||||
* Optional shorthand to set both `preview_output.mediaType` and an
|
||||
* extension-appropriate filename. Ignored when `preview_output` is also
|
||||
* supplied via `overrides`.
|
||||
*/
|
||||
mediaKind?: MediaKindFixture
|
||||
}
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const { mediaKind, ...rest } = overrides
|
||||
const now = Date.now()
|
||||
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
|
||||
const mediaType = mediaKind ?? 'images'
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${rest.id}.${extension}`,
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...rest
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,46 +54,6 @@ export function createMockJobs(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one job per requested media kind, in the order supplied. Jobs share
|
||||
* a stable timestamp ordering (newer first) so callers can rely on the result
|
||||
* order when mediaType filters are inactive.
|
||||
*/
|
||||
export function createMixedMediaJobs(
|
||||
kinds: MediaKindFixture[]
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return kinds.map((kind, i) =>
|
||||
createMockJob({
|
||||
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
|
||||
mediaKind: kind,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create jobs with explicit `(create_time, execution duration)` pairs so that
|
||||
* sort assertions for newest/oldest and longest/fastest are unambiguous.
|
||||
*
|
||||
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
|
||||
* equals `durationMs`. The first spec becomes id `job-001`, etc.
|
||||
*/
|
||||
export function createJobsWithExecutionTimes(
|
||||
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
|
||||
): RawJobListItem[] {
|
||||
return specs.map((spec, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: spec.createTime,
|
||||
execution_start_time: spec.createTime,
|
||||
execution_end_time: spec.createTime + spec.durationMs
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
@@ -168,23 +88,12 @@ function getExecutionDuration(job: RawJobListItem): number {
|
||||
|
||||
export class AssetsHelper {
|
||||
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private generatedJobs: RawJobListItem[] = []
|
||||
private cloudAssetsResponse: ListAssetsResponse | null = null
|
||||
private assetExportRequests: CreateAssetExportData['body'][] = []
|
||||
private assetExportResponse: CreateAssetExportResponse | null = null
|
||||
private importedFiles: string[] = []
|
||||
private readonly jobDetailRouteHandlers = new Map<
|
||||
string,
|
||||
(route: Route) => Promise<void>
|
||||
>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -261,82 +170,6 @@ export class AssetsHelper {
|
||||
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
|
||||
this.cloudAssetsResponse = response
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cloudAssetsRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.cloudAssetsResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
|
||||
}
|
||||
|
||||
async mockEmptyCloudAssets(): Promise<void> {
|
||||
await this.mockCloudAssets({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
})
|
||||
}
|
||||
|
||||
async captureAssetExportRequests(
|
||||
response: CreateAssetExportResponse = {
|
||||
task_id: 'asset-export-task',
|
||||
status: 'created'
|
||||
}
|
||||
): Promise<CreateAssetExportData['body'][]> {
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = response
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
this.assetExportRouteHandler = async (route: Route) => {
|
||||
this.assetExportRequests.push(
|
||||
route.request().postDataJSON() as CreateAssetExportData['body']
|
||||
)
|
||||
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.assetExportResponse)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
|
||||
|
||||
return this.assetExportRequests
|
||||
}
|
||||
|
||||
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
|
||||
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
|
||||
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
|
||||
|
||||
if (existingHandler) {
|
||||
await this.page.unroute(pattern, existingHandler)
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(detail)
|
||||
})
|
||||
}
|
||||
|
||||
this.jobDetailRouteHandlers.set(pattern, handler)
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockInputFiles(files: string[]): Promise<void> {
|
||||
this.importedFiles = [...files]
|
||||
|
||||
@@ -392,9 +225,6 @@ export class AssetsHelper {
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.cloudAssetsResponse = null
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = null
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
@@ -402,22 +232,6 @@ export class AssetsHelper {
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.cloudAssetsRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetsListRoutePattern,
|
||||
this.cloudAssetsRouteHandler
|
||||
)
|
||||
this.cloudAssetsRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.assetExportRouteHandler) {
|
||||
await this.page.unroute(
|
||||
assetExportRoutePattern,
|
||||
this.assetExportRouteHandler
|
||||
)
|
||||
this.assetExportRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
@@ -433,10 +247,5 @@ export class AssetsHelper {
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
|
||||
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.jobDetailRouteHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class CanvasHelper {
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
async toAbsolute(position: Position): Promise<Position> {
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
@@ -150,28 +150,6 @@ export class CanvasHelper {
|
||||
await nextFrame(this.page)
|
||||
}
|
||||
|
||||
async getOffset(): Promise<[number, number]> {
|
||||
return this.page.evaluate(
|
||||
() => [...window.app!.canvas.ds.offset] as [number, number]
|
||||
)
|
||||
}
|
||||
|
||||
async getNodeTitleHeight(): Promise<number> {
|
||||
return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold `Control+Shift` and drag from `from` to `to` using page-absolute
|
||||
* coordinates.
|
||||
*/
|
||||
async ctrlShiftDrag(from: Position, to: Position): Promise<void> {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.down('Shift')
|
||||
await this.dragAndDrop(from, to)
|
||||
await this.page.keyboard.up('Shift')
|
||||
await this.page.keyboard.up('Control')
|
||||
}
|
||||
|
||||
async convertOffsetToCanvas(
|
||||
pos: [number, number]
|
||||
): Promise<[number, number]> {
|
||||
@@ -264,39 +242,11 @@ export class CanvasHelper {
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
async disconnectEdge(
|
||||
options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {}
|
||||
): Promise<void> {
|
||||
const { modifiers = [] } = options
|
||||
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||
try {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
)
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
}
|
||||
|
||||
async middleClick(position: Position): Promise<void> {
|
||||
await this.mouseClickAt(position, { button: 'middle' })
|
||||
}
|
||||
|
||||
async dblclickGroupTitle(title: string): Promise<void> {
|
||||
const clientPos = await this.page.evaluate((targetTitle) => {
|
||||
const groups = window.app!.canvas.graph?.groups ?? []
|
||||
const group = groups.find(
|
||||
(g: { title: string }) => g.title === targetTitle
|
||||
)
|
||||
if (!group) return null
|
||||
const cx = group.pos[0] + group.size[0] / 2
|
||||
const cy = group.pos[1] + group.titleHeight / 2
|
||||
return window.app!.canvasPosToClientPos([cx, cy])
|
||||
}, title)
|
||||
if (!clientPos) throw new Error(`Group "${title}" not found`)
|
||||
await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 })
|
||||
await nextFrame(this.page)
|
||||
async disconnectEdge(): Promise<void> {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
)
|
||||
}
|
||||
|
||||
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename } from 'path'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
|
||||
@@ -13,11 +13,7 @@ import type { Page } from '@playwright/test'
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
private readonly appUrl: string
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
@@ -38,7 +34,7 @@ export class CloudAuthHelper {
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto(`${this.appUrl}/api/users`)
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
|
||||