Compare commits
54 Commits
feat/toolt
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f297c24b | ||
|
|
c6de167fe9 | ||
|
|
6b02aab81a | ||
|
|
7fd35c2f82 | ||
|
|
0e5d788688 | ||
|
|
2d0fbe3822 | ||
|
|
478b39cdc5 | ||
|
|
61a6b402ae | ||
|
|
fb98dfd736 | ||
|
|
cee63169ba | ||
|
|
92561d2f5c | ||
|
|
1f80ca56e5 | ||
|
|
3e3625bf95 | ||
|
|
a1aeb5254f | ||
|
|
ef44acf16d | ||
|
|
fded789d9f | ||
|
|
2c01be33bd | ||
|
|
f1a058ea4f | ||
|
|
b9a4f86713 | ||
|
|
618c32416f | ||
|
|
fd02ebcf26 | ||
|
|
b2f96cc3c0 | ||
|
|
58135fed67 | ||
|
|
2f56d6727d | ||
|
|
e5a967f3a7 | ||
|
|
ff06060689 | ||
|
|
0d9171e8a3 | ||
|
|
5e570c85a8 | ||
|
|
622f9a8238 | ||
|
|
a3893a593d | ||
|
|
deba72e7a0 | ||
|
|
3db0eac353 | ||
|
|
4c7729ee0b | ||
|
|
40083d593b | ||
|
|
7089a7d1a0 | ||
|
|
3b4811b00d | ||
|
|
b756545f59 | ||
|
|
da91bdc957 | ||
|
|
cf3006f82c | ||
|
|
be2d757c47 | ||
|
|
54f3127658 | ||
|
|
6dba67da6b | ||
|
|
beaa269a63 | ||
|
|
cf98013c18 | ||
|
|
ecb7fd4796 | ||
|
|
e28c1e7e43 | ||
|
|
39dc8d896b | ||
|
|
f6f267b46d | ||
|
|
e8d833bc54 | ||
|
|
3fd3c565ae | ||
|
|
ff4c812d08 | ||
|
|
836cab1b38 | ||
|
|
7ffaff7e1b | ||
|
|
5d04df7b2c |
125
.claude/agents/playwright-test-generator.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
|
||||
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
|
||||
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
|
||||
application behavior.
|
||||
|
||||
# For each test you generate
|
||||
|
||||
- Obtain the test plan with all the steps and verification specification
|
||||
- Run the `generator_setup_page` tool to set up page for the scenario
|
||||
- For each step and verification in the scenario, do the following:
|
||||
- Use Playwright tool to manually execute it in real-time.
|
||||
- Use the step description as the intent for each Playwright tool call.
|
||||
- Retrieve generator log via `generator_read_log`
|
||||
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
|
||||
- File should contain single test
|
||||
- File name must be fs-friendly scenario name
|
||||
- Test must be placed in a describe matching the top-level test plan item
|
||||
- Test title must match the scenario name
|
||||
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
|
||||
multiple actions.
|
||||
- Always use best practices from the log when generating tests.
|
||||
|
||||
<example-generation>
|
||||
For following plan:
|
||||
|
||||
```markdown file=specs/plan.md
|
||||
### 1. Adding New Todos
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1 Add Valid Todo
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Click in the "What needs to be done?" input field
|
||||
|
||||
#### 1.2 Add Multiple Todos
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Following file is generated:
|
||||
|
||||
```ts file=add-valid-todo.spec.ts
|
||||
// spec: specs/plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
|
||||
test.describe('Adding New Todos', () => {
|
||||
test('Add Valid Todo', async { page } => {
|
||||
// 1. Click in the "What needs to be done?" input field
|
||||
await page.click(...);
|
||||
|
||||
...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
</example-generation>
|
||||
|
||||
## ComfyUI Project Context
|
||||
|
||||
### Required Import Pattern
|
||||
|
||||
Generated tests MUST use ComfyUI fixtures, not generic `@playwright/test`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
```
|
||||
|
||||
### Fixture Object
|
||||
|
||||
Tests receive `comfyPage` (not `page`) as their fixture:
|
||||
|
||||
```typescript
|
||||
test('my test', async ({ comfyPage }) => {
|
||||
// Access raw page via comfyPage.page if needed
|
||||
})
|
||||
```
|
||||
|
||||
### Key APIs
|
||||
|
||||
| Need | Use | Notes |
|
||||
| ---------------- | ---------------------------------------------------- | --------------------------------- |
|
||||
| Canvas element | `comfyPage.canvas` | Pre-configured Locator |
|
||||
| Wait for render | `comfyPage.nextFrame()` | After canvas mutations |
|
||||
| Load workflow | `comfyPage.workflow.loadWorkflow('name')` | Assets in `browser_tests/assets/` |
|
||||
| Get node by type | `comfyPage.nodeOps.getNodeRefsByType('KSampler')` | Returns NodeReference[] |
|
||||
| Search box | `comfyPage.searchBox.fillAndSelectFirstNode('name')` | Opens on canvas dblclick |
|
||||
| Settings | `comfyPage.settings.setSetting(key, value)` | Clean up in afterEach |
|
||||
| Keyboard | `comfyPage.keyboard.press('Delete')` | Focus canvas first |
|
||||
| Context menu | `comfyPage.contextMenu` | Right-click interactions |
|
||||
|
||||
### Mandatory Test Structure
|
||||
|
||||
Every generated test must:
|
||||
|
||||
1. Be wrapped in `test.describe('Name', { tag: ['@canvas'] }, () => { ... })`
|
||||
2. Include `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })`
|
||||
3. Use descriptive test names (not "test" or "test1")
|
||||
|
||||
### Anti-Patterns — NEVER Use
|
||||
|
||||
- ❌ `page.goto()` — fixture handles navigation
|
||||
- ❌ `page.waitForTimeout()` — use `comfyPage.nextFrame()` or retrying assertions
|
||||
- ❌ `import from '@playwright/test'` — use `from '../fixtures/ComfyPage'`
|
||||
- ❌ Bare `page.` references — use `comfyPage.page.` if you need raw page access
|
||||
|
||||
### Reference
|
||||
|
||||
Read the fixture code for full API surface:
|
||||
|
||||
- `browser_tests/fixtures/ComfyPage.ts` — main fixture
|
||||
- `browser_tests/fixtures/helpers/` — helper classes
|
||||
- `browser_tests/fixtures/components/` — page object components
|
||||
- See also: `.claude/skills/codegen-transform/SKILL.md` for transform rules
|
||||
91
.claude/agents/playwright-test-healer.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent when you need to debug and fix failing Playwright tests
|
||||
tools: Glob, Grep, Read, LS, Edit, MultiEdit, Write, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
|
||||
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
|
||||
broken Playwright tests using a methodical approach.
|
||||
|
||||
Your workflow:
|
||||
|
||||
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
|
||||
2. **Debug failed tests**: For each failing test run `test_debug`.
|
||||
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
|
||||
- Examine the error details
|
||||
- Capture page snapshot to understand the context
|
||||
- Analyze selectors, timing issues, or assertion failures
|
||||
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
|
||||
- Element selectors that may have changed
|
||||
- Timing and synchronization issues
|
||||
- Data dependencies or test environment problems
|
||||
- Application changes that broke test assumptions
|
||||
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
|
||||
- Updating selectors to match current application state
|
||||
- Fixing assertions and expected values
|
||||
- Improving test reliability and maintainability
|
||||
- For inherently dynamic data, utilize regular expressions to produce resilient locators
|
||||
6. **Verification**: Restart the test after each fix to validate the changes
|
||||
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
|
||||
|
||||
Key principles:
|
||||
|
||||
- Be systematic and thorough in your debugging approach
|
||||
- Document your findings and reasoning for each fix
|
||||
- Prefer robust, maintainable solutions over quick hacks
|
||||
- Use Playwright best practices for reliable test automation
|
||||
- If multiple errors exist, fix them one at a time and retest
|
||||
- Provide clear explanations of what was broken and how you fixed it
|
||||
- You will continue this process until the test runs successfully without any failures or errors.
|
||||
- If the error persists and you have high confidence the test is correct, do not auto-skip by default.
|
||||
- Summarize root-cause evidence and escalate as a likely app regression.
|
||||
- Use `test.fixme()` only when a known issue is documented and referenced, and include a short rationale comment.
|
||||
Auto-skipping can mask real regressions — require explicit justification.
|
||||
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
|
||||
- Never wait for networkidle or use other discouraged or deprecated apis
|
||||
|
||||
## ComfyUI Project Context
|
||||
|
||||
### Custom Fixtures
|
||||
|
||||
Tests in this project use `comfyPage` fixture, not bare `page`. When healing:
|
||||
|
||||
- Replace any `page.` references with `comfyPage.page.` if adding new code
|
||||
- Use `comfyPage.nextFrame()` instead of adding `waitForTimeout()`
|
||||
- Use fixture helpers (`comfyPage.nodeOps`, `comfyPage.canvas`, etc.) over raw locators
|
||||
|
||||
### Common Failure Causes in ComfyUI Tests
|
||||
|
||||
1. **Missing `nextFrame()`**: Canvas operations need `await comfyPage.nextFrame()` after mutations. This is the #1 cause of "works locally, fails in CI" issues.
|
||||
|
||||
2. **Canvas focus required**: Keyboard shortcuts won't work unless `await comfyPage.canvas.click()` is called first.
|
||||
|
||||
3. **Node position drift**: Pixel coordinates can shift between environments. When possible, replace with node references:
|
||||
|
||||
```typescript
|
||||
// Instead of: canvas.click({ position: { x: 423, y: 267 } })
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
await node.click('title')
|
||||
```
|
||||
|
||||
4. **Settings pollution**: Settings persist across tests on the backend. Always reset changed settings in `afterEach`.
|
||||
|
||||
5. **Drag animation timing**: Use `{ steps: 10 }` option for drag operations, not `{ steps: 1 }`.
|
||||
|
||||
### Healing Safety Rules
|
||||
|
||||
- ❌ NEVER add `waitForTimeout()` — always use retrying assertions or `nextFrame()`
|
||||
- ❌ NEVER "fix" a test by weakening assertions (e.g., removing an assertion that fails)
|
||||
- ❌ NEVER modify the application code — only modify test code
|
||||
- ⚠️ If a test fails because expected UI elements are missing, the app may have a regression — mark as `test.fixme()` with explanation, don't "heal" the assertion away
|
||||
- ⚠️ If a test fails only in CI but passes locally, likely missing `nextFrame()` — don't mask with timeouts
|
||||
|
||||
### Reference
|
||||
|
||||
- `browser_tests/fixtures/ComfyPage.ts` — full fixture API
|
||||
- `browser_tests/fixtures/helpers/` — available helper classes
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — testing conventions
|
||||
- `.claude/skills/codegen-transform/SKILL.md` — transform rules
|
||||
89
.claude/agents/playwright-test-planner.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: playwright-test-planner
|
||||
description: Use this agent when you need to create comprehensive test plan for a web application or website
|
||||
tools: Glob, Grep, Read, LS, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_run_code, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page, mcp__playwright-test__planner_save_plan
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
|
||||
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
|
||||
planning.
|
||||
|
||||
You will:
|
||||
|
||||
1. **Navigate and Explore**
|
||||
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
|
||||
- Explore the browser snapshot
|
||||
- Do not take screenshots unless absolutely necessary
|
||||
- Use `browser_*` tools to navigate and discover interface
|
||||
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
|
||||
|
||||
2. **Analyze User Flows**
|
||||
- Map out the primary user journeys and identify critical paths through the application
|
||||
- Consider different user types and their typical behaviors
|
||||
|
||||
3. **Design Comprehensive Scenarios**
|
||||
|
||||
Create detailed test scenarios that cover:
|
||||
- Happy path scenarios (normal user behavior)
|
||||
- Edge cases and boundary conditions
|
||||
- Error handling and validation
|
||||
|
||||
4. **Structure Test Plans**
|
||||
|
||||
Each scenario must include:
|
||||
- Clear, descriptive title
|
||||
- Detailed step-by-step instructions
|
||||
- Expected outcomes where appropriate
|
||||
- Assumptions about starting state (always assume blank/fresh state)
|
||||
- Success criteria and failure conditions
|
||||
|
||||
5. **Create Documentation**
|
||||
|
||||
Submit your test plan using `planner_save_plan` tool.
|
||||
|
||||
**Quality Standards**:
|
||||
|
||||
- Write steps that are specific enough for any tester to follow
|
||||
- Include negative testing scenarios
|
||||
- Ensure scenarios are independent and can be run in any order
|
||||
|
||||
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
|
||||
professional formatting suitable for sharing with development and QA teams.
|
||||
|
||||
## ComfyUI Project Context
|
||||
|
||||
### Application Overview
|
||||
|
||||
ComfyUI is a **canvas-based node graph editor** for AI image generation. It is a complex SPA with:
|
||||
|
||||
- A **LiteGraph canvas** where users create workflows by connecting nodes
|
||||
- A **Vue 3 sidebar** with node library, workflows panel, and settings
|
||||
- A **topbar** with queue/run buttons and workspace controls
|
||||
- A **search box** for finding and adding nodes (opens on double-click)
|
||||
- WebSocket-based real-time communication with a Python backend
|
||||
|
||||
### Exploration Tips
|
||||
|
||||
- Start by loading a workflow: the app is most useful with nodes on the canvas
|
||||
- Key UI areas to explore: canvas interactions, sidebar panels, topbar buttons, search box, context menus, settings dialog
|
||||
- Double-click the canvas to open the node search box
|
||||
- Right-click nodes/canvas for context menus
|
||||
- The bottom panel shows job queue and execution logs
|
||||
|
||||
### Test Environment
|
||||
|
||||
- The seed test uses `comfyPageFixture` which provides a `comfyPage` object with extensive helpers
|
||||
- Workflows (JSON files) are loaded via `comfyPage.workflow.loadWorkflow('name')`
|
||||
- Available workflow assets are in `browser_tests/assets/`
|
||||
- The backend MUST be running with `--multi-user` flag
|
||||
- A Vite dev server runs on `:5173`
|
||||
|
||||
### When Creating Test Plans
|
||||
|
||||
- Reference specific workflow assets when a scenario needs a starting state
|
||||
- Note that canvas interactions use pixel coordinates — these may vary across environments
|
||||
- Distinguish between "canvas tests" (LiteGraph) and "UI tests" (Vue components)
|
||||
- Include tags in your plans: `@canvas`, `@widget`, `@sidebar`, `@smoke`, `@screenshot`
|
||||
- Reference `browser_tests/fixtures/ComfyPage.ts` for available test helpers
|
||||
95
.claude/skills/codegen-transform/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: codegen-transform
|
||||
description: 'Transforms raw Playwright codegen output into ComfyUI convention-compliant tests. Use when: user pastes raw codegen, asks to convert raw Playwright code, refactor recorded tests, or rewrite to project conventions. Triggers on: transform codegen, convert raw test, rewrite to conventions, codegen output, raw playwright.'
|
||||
---
|
||||
|
||||
# Codegen → Convention Transform
|
||||
|
||||
Transform raw Playwright codegen output into tests that follow ComfyUI conventions.
|
||||
|
||||
## When to Use
|
||||
|
||||
- QA tester recorded a test with `pnpm comfy-test record` and wants refinement
|
||||
- Developer pasted raw `npx playwright codegen` output
|
||||
- Agent needs to post-process Playwright test agent output
|
||||
- Reviewing a test that uses raw `page.*` calls instead of fixture helpers
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
Before transforming, read these existing docs for full context:
|
||||
|
||||
| Document | What it covers |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `docs/guidance/playwright.md` | Playwright conventions, type assertions, assertion best practices, tags |
|
||||
| `browser_tests/AGENTS.md` | Directory structure, polling assertions, gotchas, quality checks |
|
||||
| `browser_tests/fixtures/ComfyPage.ts` | Main fixture API (source of truth for all helpers) |
|
||||
| `browser_tests/fixtures/helpers/` | Focused helper classes (canvas, keyboard, workflow, etc.) |
|
||||
|
||||
## Transform Rules
|
||||
|
||||
The programmatic transform engine lives in `tools/test-recorder/src/transform/rules.ts`. Apply these replacements in order:
|
||||
|
||||
| Raw codegen | Convention replacement | Why |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `import { test, expect } from '@playwright/test'` | `import { comfyPageFixture as test, comfyExpect as expect } from '../fixtures/ComfyPage'` | Use custom fixtures with ComfyUI helpers |
|
||||
| `test('test', async ({ page }) =>` | `test('descriptive-name', async ({ comfyPage }) =>` | Use comfyPage fixture, descriptive names |
|
||||
| `await page.goto('http://...')` | **Remove entirely** | Fixture handles navigation automatically |
|
||||
| `page.locator('canvas')` | `comfyPage.canvas` | Pre-configured canvas locator |
|
||||
| `page.waitForTimeout(N)` | `comfyPage.nextFrame()` | Never use arbitrary waits |
|
||||
| `page.getByPlaceholder('Search Nodes...')` | `comfyPage.searchBox.input` | Use search box page object |
|
||||
| `page` (bare reference) | `comfyPage.page` | Access raw page through fixture |
|
||||
| Bare `test(...)` | `test.describe('Feature', { tag: ['@canvas'] }, () => { test(...) })` | All tests need describe + tags |
|
||||
| No cleanup | Add `test.afterEach(async ({ comfyPage }) => { await comfyPage.canvasOps.resetView() })` | Canvas tests need cleanup |
|
||||
|
||||
## Canvas Coordinates → Node References
|
||||
|
||||
Raw codegen records fragile pixel coordinates. Replace with node references when possible:
|
||||
|
||||
```typescript
|
||||
// ❌ Raw codegen — fragile pixel coordinates
|
||||
await page.locator('canvas').click({ position: { x: 423, y: 267 } })
|
||||
|
||||
// ✅ If clicking a specific node
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
await node.click('title')
|
||||
|
||||
// ✅ If double-clicking canvas to open search
|
||||
await comfyPage.canvas.dblclick({ position: { x: 500, y: 400 } })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
```
|
||||
|
||||
**When to keep coordinates**: Canvas background clicks (pan, zoom), empty area clicks to deselect. These are inherently position-based.
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Question | Answer |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Canvas or DOM interaction? | Canvas: `comfyPage.nodeOps.*`. DOM: `comfyPage.vueNodes.*` (needs opt-in) |
|
||||
| Need `nextFrame()`? | Yes after canvas mutations. No after `loadWorkflow()`, no after DOM clicks |
|
||||
| Which tag? | `@canvas` for canvas tests, `@widget` for widget tests, `@screenshot` for visual regression |
|
||||
| Need cleanup? | Yes for canvas tests (`resetView`), yes if changing settings (`setSetting` back) |
|
||||
| Keep pixel coords? | Only for empty canvas clicks. Replace with node refs for node interactions |
|
||||
| Use `page` directly? | Only via `comfyPage.page` for Playwright APIs not wrapped by fixtures |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
1. **Never use `waitForTimeout`** → use `nextFrame()` or retrying assertions
|
||||
2. **Never use `page.goto`** → fixture handles navigation
|
||||
3. **Never import from `@playwright/test`** → use `../fixtures/ComfyPage`
|
||||
4. **Never use bare CSS selectors** → use test IDs or semantic locators
|
||||
5. **Never share state between tests** → each test is independent
|
||||
6. **Never commit local screenshots** → Linux CI generates baselines
|
||||
|
||||
## For Deeper Reference
|
||||
|
||||
Read fixture code directly — it's the source of truth:
|
||||
|
||||
| Purpose | Path |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
|
||||
| Helper classes | `browser_tests/fixtures/helpers/` |
|
||||
| Component objects | `browser_tests/fixtures/components/` |
|
||||
| Test selectors | `browser_tests/fixtures/selectors.ts` |
|
||||
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
|
||||
| Existing tests | `browser_tests/tests/` |
|
||||
| Test assets | `browser_tests/assets/` |
|
||||
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -98,3 +98,50 @@ jobs:
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html/
|
||||
retention-days: 30
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download HTML report
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html
|
||||
|
||||
- name: Upload to GitHub Pages
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: coverage/html
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
|
||||
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: 'CI: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
run: |
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: vercel-preview
|
||||
path: temp/vercel-preview
|
||||
|
||||
deploy-production:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
74
.github/workflows/pr-vercel-website-preview.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: 'PR: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Vercel Website Preview']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download preview metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: vercel-preview
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/vercel-preview
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
|
||||
- name: Read preview URL
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: meta
|
||||
run: |
|
||||
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./preview-report.md
|
||||
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
1
.gitignore
vendored
@@ -59,6 +59,7 @@ coverage/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser_tests/local/
|
||||
browser_tests/tests/_recording-session.spec.ts
|
||||
|
||||
.env
|
||||
|
||||
|
||||
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-test": {
|
||||
"command": "pnpm",
|
||||
"args": ["dlx", "@playwright/mcp@0.0.68"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"dist/*",
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"playwright-report/*",
|
||||
"scripts/patch-playwright-agents.js",
|
||||
"src/extensions/core/*",
|
||||
"src/scripts/*",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
@@ -115,6 +116,12 @@
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tools/test-recorder/**/*.ts"],
|
||||
"rules": {
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["browser_tests/**/*.ts"],
|
||||
"jsPlugins": ["eslint-plugin-playwright"],
|
||||
|
||||
15
AGENTS.md
@@ -49,6 +49,21 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm comfy-test record`: Interactive test recorder (guided setup for non-devs)
|
||||
- `pnpm comfy-test transform <file>`: Transform raw codegen to conventions
|
||||
- `pnpm comfy-test check`: Check environment prerequisites
|
||||
- `pnpm comfy-test list`: List available test workflows
|
||||
|
||||
### Playwright Test Agents (`.claude/agents/`)
|
||||
|
||||
| Agent | Responsibility |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `playwright-test-planner.md` | Explores the app, identifies testable scenarios, creates structured test plans |
|
||||
| `playwright-test-generator.md` | Generates Playwright test code from plans using ComfyUI fixtures and conventions |
|
||||
| `playwright-test-healer.md` | Diagnoses and fixes failing tests; escalates regressions rather than auto-skipping |
|
||||
|
||||
Guardrails: agents must use `comfyPage` fixture (not bare `page`), never add `waitForTimeout()`, never weaken assertions, and reference `.claude/skills/codegen-transform/SKILL.md` for transform rules.
|
||||
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
|
||||
137
apps/website/e2e/cloud.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Cloud page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /The full power of/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/The easiest way to start with ComfyUI/)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('HeroSection has CTA button linking to cloud', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /TRY COMFY CLOUD FOR FREE/i })
|
||||
await expect(cta).toBeVisible()
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
).toBeVisible()
|
||||
|
||||
for (const title of [
|
||||
'Powerful GPUs',
|
||||
'All models',
|
||||
'More control',
|
||||
'Community workflows'
|
||||
]) {
|
||||
await expect(page.getByText(title).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /leading AI models/i })
|
||||
).toBeVisible()
|
||||
|
||||
const grid = page.locator('.grid', {
|
||||
has: page.getByText('Grok Imagine')
|
||||
})
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
const cta = page.getByRole('link', {
|
||||
name: /EXPLORE WORKFLOWS/i
|
||||
})
|
||||
await expect(cta.first()).toBeVisible()
|
||||
await expect(cta.first()).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/workflows'
|
||||
)
|
||||
})
|
||||
|
||||
test('AudienceSection heading and cards are visible', async ({ page }) => {
|
||||
await expect(page.getByText(/creators/i).first()).toBeVisible()
|
||||
|
||||
for (const label of ['CREATORS', 'TEAMS & STUDIOS']) {
|
||||
await expect(page.getByText(label).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('PricingSection heading and CTA are visible', async ({ page }) => {
|
||||
await expect(page.getByText(/Simple, credit-based pricing/)).toBeVisible()
|
||||
|
||||
const cta = page.getByRole('link', { name: /SEE PRICING PLANS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', '/cloud/pricing')
|
||||
})
|
||||
|
||||
test('ProductCardsSection has 3 product cards', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
const cards = section.locator('a[href]')
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('FAQSection heading is visible with 15 items', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
|
||||
|
||||
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
|
||||
await expect(faqButtons).toHaveCount(15)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud FAQ accordion @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud')
|
||||
})
|
||||
|
||||
test('all FAQs are expanded by default', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking an expanded FAQ collapses it', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /What is Comfy Cloud/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await firstQuestion.click()
|
||||
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /What is Comfy Cloud/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeHidden()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
167
apps/website/e2e/download.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Download page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/download')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
|
||||
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('HeroSection has download and GitHub buttons', async ({ page }) => {
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
await expect(githubBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Comfy-Org/ComfyUI'
|
||||
)
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
).toBeVisible()
|
||||
|
||||
for (const title of [
|
||||
'Unlimited',
|
||||
'Any model',
|
||||
'Your machine',
|
||||
'Free. Open Source'
|
||||
]) {
|
||||
await expect(page.getByText(title).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('EcoSystemSection heading is visible', async ({ page }) => {
|
||||
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('ProductCardsSection has 3 product cards', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
const cards = section.locator('a[href]')
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('ProductCardsSection links to cloud, api, enterprise', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
|
||||
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
|
||||
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('FAQSection heading is visible with 8 items', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
|
||||
|
||||
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
|
||||
await expect(faqButtons).toHaveCount(8)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('FAQ accordion @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/download')
|
||||
})
|
||||
|
||||
test('all FAQs are expanded by default', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking an expanded FAQ collapses it', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /Do I need a GPU/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await firstQuestion.click()
|
||||
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /Do I need a GPU/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeHidden()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Download page mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/download')
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible', async ({ page }) => {
|
||||
await expect(page.getByText(/Need more power/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('HeroSection heading is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('download buttons are stacked vertically', async ({ page }) => {
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
|
||||
await downloadBtn.scrollIntoViewIfNeeded()
|
||||
|
||||
const downloadBox = await downloadBtn.boundingBox()
|
||||
const githubBox = await githubBtn.boundingBox()
|
||||
|
||||
expect(downloadBox, 'download button bounding box').not.toBeNull()
|
||||
expect(githubBox, 'github button bounding box').not.toBeNull()
|
||||
expect(githubBox!.y).toBeGreaterThan(downloadBox!.y)
|
||||
})
|
||||
})
|
||||
13
apps/website/public/icons/ai-models/bytedance.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="32" height="32" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1471_12658)">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z" fill="white"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z" fill="white"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z" fill="white"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1471_12658">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
apps/website/public/icons/ai-models/gemini.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M31.0589 13.0316H16.014V19.1831H24.6589C24.5199 20.0537 24.2078 20.9101 23.7509 21.691C23.2273 22.5858 22.5801 23.2669 21.9167 23.7857C19.9294 25.3395 17.6126 25.6572 16.0035 25.6572C11.9389 25.6572 8.46594 22.9766 7.12148 19.3341C7.06723 19.2019 7.0312 19.0654 6.98733 18.9304C6.69023 18.0034 6.5279 17.0215 6.5279 16.001C6.5279 14.939 6.70369 13.9223 7.0242 12.9621C8.28844 9.17522 11.8397 6.34675 16.0064 6.34675C16.8445 6.34675 17.6516 6.44854 18.417 6.65159C20.1661 7.11561 21.4034 8.0295 22.1615 8.75237L26.7361 4.18101C23.9534 1.57756 20.3259 3.9369e-09 15.9988 3.9369e-09C12.5396 -7.59723e-05 9.34593 1.09971 6.72881 2.95838C4.60641 4.46571 2.86573 6.48384 1.69099 8.82767C0.598311 11.0009 0 13.4092 0 15.9986C0 18.5881 0.599225 21.0215 1.69191 23.1746V23.1891C2.84605 25.4749 4.5338 27.4431 6.58508 28.9435C8.3771 30.2543 11.5904 32 15.9988 32C18.534 32 20.7809 31.5336 22.7625 30.6595C24.192 30.029 25.4585 29.2066 26.6052 28.1496C28.1203 26.753 29.3069 25.0255 30.1168 23.038C30.9268 21.0505 31.36 18.8029 31.36 16.3662C31.36 15.2314 31.2483 14.0791 31.0589 13.0316Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
apps/website/public/icons/ai-models/grok.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="31" viewBox="0 0 32 31" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.3587 19.8988L22.9972 11.9646C23.5187 11.5757 24.2642 11.7274 24.5127 12.3316C25.8207 15.5179 25.2363 19.3471 22.634 21.9762C20.0318 24.6052 16.411 25.1818 13.1015 23.8686L9.48617 25.5598C14.6716 29.1406 20.9684 28.255 24.9032 24.277C28.0243 21.1237 28.9909 16.8255 28.0871 12.9496L28.0952 12.9578C26.7845 7.26377 28.4175 4.98781 31.7625 0.333808C31.8416 0.223459 31.9208 0.113108 32 0L27.5982 4.44709V4.4333L12.356 19.9016" fill="white"/>
|
||||
<path d="M10.1634 21.8272C6.44151 18.2353 7.0832 12.6764 10.2589 9.47079C12.6072 7.09824 16.4546 6.12993 19.8133 7.55344L23.4204 5.87061C22.7706 5.3961 21.9377 4.88574 20.982 4.52709C16.6622 2.73116 11.4904 3.62499 7.97884 7.16997C4.60108 10.5825 3.53887 15.8297 5.36292 20.3071C6.72549 23.6535 4.49185 26.0204 2.24183 28.4096C1.44449 29.2564 0.644421 30.1034 0 31L10.1606 21.8299" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 952 B |
4
apps/website/public/icons/ai-models/qwen.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.1935 32L24.9858 15.5745L27.22 19.5198H31.4952L27.22 12.0998L29.4765 8.3063H12.2615L14.5178 4.36093L12.2615 0.332235L7.41455 8.3063H2.39172L11.6896 23.9422H7.41455L4.85911 27.9711H13.194L15.6878 32L15.6431 31.9985C15.4212 31.9838 15.2231 31.8597 15.1159 31.668L13.1938 28.3031H4.85911C4.6194 28.3031 4.40156 28.1756 4.2872 27.9711L2.03092 24.1776C1.85061 23.8609 1.8506 23.4694 2.03092 23.1527L3.95303 19.7881L0.135229 12.9026C-0.0450889 12.586 -0.0450637 12.1944 0.135229 11.8778L2.39151 8.08424C2.50586 7.87972 2.72371 7.75222 2.96341 7.75222H7.41455L11.6896 0.332235C11.8039 0.127714 12.0218 0 12.2615 0H16.7124C16.9521 0 17.17 0.127714 17.2843 0.332235L19.2064 3.69689H27.5411C27.7808 3.69689 27.9986 3.82439 28.113 4.02891L30.3693 7.82245C30.5496 8.13912 30.5496 8.53061 30.3693 8.84729L28.113 12.6408L31.6316 19.0976C31.8119 19.4143 31.8119 19.8058 31.6316 20.1224L29.3751 23.916C29.2608 24.1204 29.0431 24.248 28.8034 24.248H24.9856L20.7106 31.668C20.5962 31.8725 20.3784 32 20.1387 32H16.1935Z" fill="white"/>
|
||||
<path d="M22.0609 13.703H10.6869L16.1935 23.001L22.0609 13.703Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
apps/website/public/icons/ai-models/wan.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.1935 32L24.9858 15.5745L27.22 19.5198H31.4952L27.22 12.0998L29.4765 8.3063H12.2615L14.5178 4.36093L12.2615 0.332235L7.41455 8.3063H2.39172L11.6896 23.9422H7.41455L4.85911 27.9711H13.194L15.6878 32L15.6431 31.9985C15.4212 31.9838 15.2231 31.8597 15.1159 31.668L13.1938 28.3031H4.85911C4.6194 28.3031 4.40156 28.1756 4.2872 27.9711L2.03092 24.1776C1.85061 23.8609 1.8506 23.4694 2.03092 23.1527L3.95303 19.7881L0.135229 12.9026C-0.0450889 12.586 -0.0450637 12.1944 0.135229 11.8778L2.39151 8.08424C2.50586 7.87972 2.72371 7.75222 2.96341 7.75222H7.41455L11.6896 0.332235C11.8039 0.127714 12.0218 0 12.2615 0H16.7124C16.9521 0 17.17 0.127714 17.2843 0.332235L19.2064 3.69689H27.5411C27.7808 3.69689 27.9986 3.82439 28.113 4.02891L30.3693 7.82245C30.5496 8.13912 30.5496 8.53061 30.3693 8.84729L28.113 12.6408L31.6316 19.0976C31.8119 19.4143 31.8119 19.8058 31.6316 20.1224L29.3751 23.916C29.2608 24.1204 29.0431 24.248 28.8034 24.248H24.9856L20.7106 31.668C20.5962 31.8725 20.3784 32 20.1387 32H16.1935Z" fill="white"/>
|
||||
<path d="M22.0609 13.703H10.6869L16.1935 23.001L22.0609 13.703Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
apps/website/public/icons/arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 13L7 7L1 0.999999" stroke="#211927" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 215 B |
15
apps/website/public/icons/investors/chemistry.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="125" height="23" viewBox="0 0 125 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M102.624 13.5714H102.72L105.521 4.7427H109.112L104.492 17.6087C102.983 21.8406 101.427 22.4 99.2481 22.4C98.4106 22.4 97.6921 22.3513 97.2372 22.3027V19.5059L97.4957 19.5312C97.7832 19.562 98.1651 19.6032 98.7218 19.6032C99.8707 19.6032 100.326 19.1652 100.828 17.9005L100.996 17.5114L95.9202 4.7427H99.5359L102.624 13.5714Z" fill="#C2BFB9"/>
|
||||
<path d="M6.34494 4.45081C10.3191 4.45087 11.8513 6.95585 12.3302 8.51244L9.07418 9.24216C8.79582 8.34683 8.13559 7.08628 6.50571 7.00832L6.34494 7.00484C4.57317 7.00484 3.42365 8.41543 3.42365 11.1881C3.42369 13.9606 4.5732 15.3713 6.34494 15.3713C8.16444 15.3713 8.85871 14.009 9.12207 12.866L12.5218 13.2795C11.9951 15.4197 10.3193 17.9248 6.34494 17.9249C2.41839 17.9249 6.38906e-05 15.3226 0 11.1881C0 7.05343 2.41834 4.45081 6.34494 4.45081Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.1848 4.4528C37.0563 4.53422 39.6265 7.17738 39.3201 11.9178H30.0783C30.1263 14.0337 31.2519 15.5173 32.9996 15.5173C34.8431 15.5172 35.5373 14.3496 35.8007 13.5227L39.2009 13.7903C38.6981 15.6144 37.0219 17.9248 32.9996 17.9249C29.121 17.9249 26.6547 15.2983 26.6547 11.2124C26.6547 7.0777 29.0491 4.45081 32.9996 4.45081L33.1848 4.4528ZM32.9996 6.81025C31.2279 6.81025 30.246 8.12338 30.1262 9.70432H35.8965C35.7767 8.12342 34.7953 6.81029 32.9996 6.81025Z" fill="#C2BFB9"/>
|
||||
<path d="M71.9332 4.45081C74.878 4.45081 77.129 5.95871 77.5359 8.19622L74.4474 8.63406C74.1924 7.40883 73.2402 6.75409 72.049 6.71444L71.9332 6.71295C70.7363 6.71296 69.874 7.24781 69.874 8.22055C69.874 9.16907 70.8078 9.41244 72.1008 9.65567L73.3939 9.85026C75.6682 10.2394 77.8711 10.9204 77.8711 13.8146C77.8711 16.6116 75.357 17.9248 72.0529 17.9249C68.7253 17.9249 66.4501 16.3684 66.0193 13.8632L69.1561 13.4254L69.1854 13.5669C69.5113 15.0012 70.5693 15.6632 72.0769 15.6632C73.5135 15.6632 74.4474 15.0307 74.4474 13.9362C74.4474 13.0364 73.5135 12.6713 72.0769 12.4281L70.6881 12.2097C68.653 11.8692 66.4982 11.1391 66.4982 8.36649C66.4983 5.9101 68.9644 4.45082 71.9332 4.45081Z" fill="#C2BFB9"/>
|
||||
<path d="M112.823 17.6871H109.232V14.0385H112.823V17.6871Z" fill="#C2BFB9"/>
|
||||
<path d="M116.914 17.6871H113.323V14.039H116.914V17.6871Z" fill="#C2BFB9"/>
|
||||
<path d="M124.6 17.6871H117.382V14.039H124.6V17.6871Z" fill="#C2BFB9"/>
|
||||
<path d="M83.5192 4.7427H86.1531V7.02916H83.5192V13.693C83.5192 14.8604 83.9504 14.9821 85.0038 14.9821C85.339 14.9821 85.7696 14.9822 86.0569 14.9578V17.6087C85.7454 17.6573 84.6924 17.6573 83.8784 17.6573C81.2928 17.6573 80.1674 17.0981 80.1674 14.5686V7.02916H78.156V4.7427H80.1674V1.14323H83.5192V4.7427Z" fill="#C2BFB9"/>
|
||||
<path d="M55.8226 4.45081C58.121 4.45081 60.0604 5.88579 60.0604 9.14486V17.633H56.7325V10.0205C56.7325 7.95318 55.9182 7.19943 54.5296 7.19943C53.2366 7.19953 52.0638 8.31817 52.0638 10.6043V17.633H48.7115V10.0205C48.7115 7.95321 47.9216 7.19945 46.533 7.19943C45.24 7.19943 44.0666 8.31807 44.0666 10.6043V17.633H40.7148V4.7427H43.9229V6.46971H44.0187C44.8807 5.2293 46.1261 4.45081 47.826 4.45081C49.3822 4.45087 50.795 5.10777 51.513 6.56701C52.5428 5.35093 53.7876 4.45086 55.8226 4.45081Z" fill="#C2BFB9"/>
|
||||
<path d="M16.9677 6.44539H17.0635C17.9493 5.20501 19.2662 4.45085 21.0139 4.45081C23.3603 4.45081 25.3714 5.88579 25.3714 9.14486V17.6087H22.0436V10.0205C22.0436 7.95328 21.1817 7.19951 19.6496 7.19943C18.3088 7.19943 16.9677 8.46404 16.9677 10.7746V17.6087H13.6159V0H16.9677V6.44539Z" fill="#C2BFB9"/>
|
||||
<path d="M65.0639 17.6087H61.7121V4.7427H65.0639V17.6087Z" fill="#C2BFB9"/>
|
||||
<path d="M94.6844 4.45578C94.9211 4.46603 95.1545 4.49342 95.3338 4.54811V7.73407C95.0226 7.68543 94.5196 7.63677 94.0173 7.63677C92.1019 7.6368 90.6895 8.4884 90.6895 11.1151V17.6087H87.3372V4.7427H90.5458V6.71295H90.6411C91.3116 5.39959 92.5809 4.45081 94.4484 4.45081L94.6844 4.45578Z" fill="#C2BFB9"/>
|
||||
<path d="M65.0639 2.94272H61.7121V0H65.0639V2.94272Z" fill="#C2BFB9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
7
apps/website/public/icons/investors/craft.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="105" height="15" viewBox="0 0 105 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.539 0.0285426C22.6544 -0.0351846 25.988 0.0279171 29.1212 0.0264352L32.1742 0.0199332C32.9185 0.018159 33.8039 -0.0152019 34.5256 0.11456C35.3509 0.266315 36.1211 0.634326 36.7577 1.18105C37.6967 1.97593 38.2784 3.11376 38.3729 4.3405C38.4839 5.82246 37.9664 7.19079 36.8332 8.15921C36.5415 8.40857 35.9614 8.79166 35.7424 9.04646C35.7383 9.43601 36.8893 11.0059 37.1467 11.4269C37.4643 11.9465 38.4111 11.7772 38.9199 11.7756C39.3943 11.7772 39.8685 11.793 40.342 11.8229C40.4037 12.5345 40.3634 13.9093 40.3517 14.664C39.0437 14.7314 37.3846 14.6838 36.0336 14.6838C34.657 14.6837 32.979 14.7319 31.644 14.6624C31.6103 14.2318 31.6332 13.4746 31.6327 13.02L31.6361 11.814C32.1247 11.7835 32.5976 11.8061 33.0846 11.7834C33.319 11.7725 33.4799 11.7405 33.5165 11.4949C33.4337 11.2769 33.1746 10.9388 33.0331 10.747C32.7459 10.3576 32.5042 9.90935 32.142 9.58871C31.967 9.43386 31.4996 9.39602 31.2878 9.39494C29.871 9.38766 28.4576 9.39504 27.0412 9.40171C26.4322 9.40545 25.8162 9.39617 25.2105 9.46037C25.198 9.95812 24.9581 11.8037 25.6817 11.7886C26.5399 11.7706 27.404 11.7843 28.2643 11.8099C28.2959 12.6677 28.2852 13.8088 28.2613 14.6625C27.0013 14.7337 25.3367 14.6856 24.0277 14.6841C22.6169 14.6825 20.913 14.7327 19.5435 14.6627C19.5121 13.7618 19.5376 12.7254 19.539 11.8145C20.0584 11.7729 21.8513 11.8658 22.1261 11.6122C22.2789 11.0808 22.2133 8.34042 22.2097 7.6769C22.206 6.9765 22.2904 3.60502 22.0911 3.13566C21.8255 2.99389 19.9813 3.03216 19.5159 3.00974C19.4729 2.62606 19.453 0.3621 19.539 0.0285426ZM25.1781 6.41333C25.7864 6.42789 26.403 6.43338 27.0112 6.4283C29.3214 6.40892 31.6425 6.48496 33.9495 6.40241C34.0841 6.39759 34.5121 6.16284 34.6315 6.08213C35.0228 5.71678 35.2992 5.35081 35.3173 4.7881C35.3308 4.32373 35.1606 3.87283 34.8436 3.53331C34.4239 3.08989 33.9742 2.99621 33.3906 2.99441C30.7387 2.98624 28.0784 3.00441 25.4273 3.02022C25.2542 3.04728 25.2743 3.01516 25.1877 3.12383C25.1324 3.75496 25.1485 5.73442 25.1781 6.41333Z" fill="#C2BFB9"/>
|
||||
<path d="M50.2724 0.138631C51.6828 0.03579 53.2496 0.182878 54.6625 0.122495C56.7584 0.0329492 58.1473 0.23673 59.6382 1.82499C61.3824 3.68336 61.5311 5.19749 61.5316 7.61398C61.5332 8.59624 61.528 9.57851 61.5157 10.5607C61.5127 10.8878 61.4609 11.4889 61.627 11.7565C61.8023 11.9292 64.2099 11.9103 64.6493 11.9184C64.6509 12.866 64.6478 13.8136 64.6391 14.7611C63.7008 14.8175 62.2832 14.7743 61.2886 14.7733C59.5065 14.7717 57.6654 14.7977 55.8904 14.7644C55.8576 13.8841 55.8771 12.8157 55.892 11.9268C56.3996 11.897 58.1832 11.9618 58.435 11.7296C58.6047 11.3174 58.598 9.85935 58.4298 9.4396C58.1735 9.22562 57.086 9.31059 56.6785 9.31623C55.7853 9.3153 48.7631 9.24582 48.3863 9.39335C48.275 9.53964 48.2383 9.77249 48.2404 9.94037C48.2611 11.6716 47.8088 11.991 49.9209 11.8975C50.1037 11.8895 50.661 11.9185 50.8594 11.9251C50.9014 12.7546 50.8803 13.9221 50.861 14.7637C50.0044 14.8179 48.6992 14.7732 47.7992 14.7732L42.1365 14.7669C42.1124 13.8551 42.1244 12.8371 42.14 11.9221C42.7563 11.9001 44.5286 11.9586 44.9981 11.8351C45.043 11.8233 45.0865 11.8065 45.1277 11.7851C45.1698 11.7632 45.2281 11.6483 45.2321 11.6095C45.5513 8.48866 44.4939 5.07258 46.6263 2.39668C47.5759 1.20517 48.672 0.362 50.2724 0.138631ZM55.8069 6.37984C56.2334 6.38256 57.2317 6.32251 57.6008 6.39251C59.4224 6.73786 58.1325 4.41869 57.5218 3.82117C57.1583 3.46885 56.7077 3.22021 56.2155 3.10105C55.5844 2.94746 52.2657 2.99145 51.4515 3.00183C51.3418 3.00391 51.2319 3.00822 51.1221 3.01476C50.2804 3.08849 49.7204 3.39435 49.1449 4.0204C48.8448 4.34691 48.0275 5.86061 48.3348 6.28005C48.8624 6.47634 54.8163 6.38015 55.8069 6.37984Z" fill="#C2BFB9"/>
|
||||
<path d="M66.1008 0.132078C67.4815 0.0948407 69.0088 0.126689 70.3982 0.126274L82.7239 0.124817C83.3335 0.124915 84.5481 0.0906206 85.1084 0.193006C85.2351 0.383285 85.1848 4.29567 85.1618 4.8387L82.2455 4.84347C82.1717 4.37331 82.2558 3.44598 82.0697 3.11536C82.0035 3.09915 81.9364 3.08718 81.8692 3.07952C81.153 2.99854 72.2869 2.97826 72.0639 3.06634C72.0337 3.07839 71.9855 3.12886 71.9578 3.15083C71.7579 3.52942 71.9947 4.54083 71.8983 5.04801C71.8814 5.21615 71.8947 5.68477 72.0527 5.78369C72.4966 6.06094 73.6589 5.94967 74.1921 5.9527L78.3526 5.97392C78.488 5.97259 78.6126 5.9569 78.7177 6.0434C78.8084 6.24682 78.7966 8.46194 78.7074 8.77012C78.2993 9.11434 72.3674 8.65905 72.0398 9.01363C71.8799 9.32283 71.8573 11.4816 71.9209 11.7506C72.1834 11.9303 74.4105 11.9116 74.8474 11.9209C74.8597 12.8671 74.8607 13.8133 74.8504 14.7595C73.9742 14.8203 72.4085 14.7764 71.4569 14.7732C69.7091 14.7674 67.8562 14.8074 66.1218 14.7628C66.0777 14.1145 66.0895 12.5701 66.1223 11.9225C66.6253 11.8922 68.4592 11.9381 68.774 11.7648C68.9098 11.2104 68.916 3.76877 68.7852 3.15653C68.5156 2.97876 66.5478 3.00636 66.091 2.9977C66.0746 2.15515 66.0464 0.952851 66.1008 0.132078Z" fill="#C2BFB9"/>
|
||||
<path d="M0 3.97075C0.680008 2.4775 1.72004 1.09533 3.30995 0.49908C3.73281 0.343706 4.17582 0.249971 4.62537 0.220763C5.88343 0.131726 17.0718 0.0347761 17.432 0.19913C17.4429 0.204062 17.4534 0.209482 17.4639 0.215036C17.5863 0.617033 17.5561 4.23071 17.5217 4.84327L14.4665 4.84676C14.4511 4.57601 14.4351 3.33928 14.2915 3.1986C14.1017 3.13857 13.8405 3.10635 13.6413 3.10753C10.8734 3.12384 8.12437 3.13586 5.3552 3.16177C4.03315 3.17415 3.10523 4.28538 2.72969 5.46977C2.44858 6.35631 2.58788 7.38144 2.55855 8.30232C2.53008 9.63214 3.18983 11.1025 4.41563 11.7148C4.93376 11.9737 5.57212 11.9398 6.13799 11.9348C8.63538 11.9151 11.1334 11.9289 13.6307 11.9075C13.8341 11.9057 14.0499 11.8752 14.2419 11.8084C14.4871 11.6127 14.4224 10.4672 14.473 10.0944C14.9745 10.0805 17.1263 10.0064 17.4428 10.1774C17.6064 10.5158 17.5389 14.096 17.5232 14.7353C14.5846 14.7664 11.6458 14.7797 8.707 14.775C7.45695 14.7749 6.20151 14.7635 4.95241 14.7698C2.61924 14.7814 0.911018 13.0104 0 11.0108V3.97075Z" fill="#C2BFB9"/>
|
||||
<path d="M105 0.127759V4.84265L102.537 4.8407C102.524 4.57401 102.556 3.11933 102.308 3.10405C101.561 3.05828 98.278 2.90385 97.7617 3.1579C97.63 3.63603 97.65 11.2212 97.7817 11.7547C98.077 11.9306 100.223 11.9104 100.712 11.9221C100.78 12.5649 100.734 14.0796 100.726 14.7638C99.8423 14.8242 98.1319 14.779 97.1696 14.7744C95.4085 14.766 93.4956 14.8141 91.7519 14.7607C91.7058 14.02 91.7155 12.6763 91.755 11.9299C92.2841 11.8996 94.2175 11.9179 94.5492 11.7937C94.7056 11.5281 94.6933 4.01885 94.6338 3.22394C94.4123 2.94404 90.2026 2.98239 89.9437 3.13285C89.7483 3.44251 89.7724 4.42536 89.7606 4.83963L86.826 4.84542C86.7767 3.83337 86.7685 1.13399 86.8301 0.135563C88.7768 0.0695231 91.0501 0.126784 93.0168 0.126836L105 0.127759Z" fill="#C2BFB9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
12
apps/website/public/icons/investors/essence.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="118" height="22" viewBox="0 0 118 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.0298 0C13.03 1.33223 13.0823 3.05715 13.0203 4.35674C12.5297 4.40883 12.0611 4.39599 11.5697 4.39984C9.20775 4.41837 6.83709 4.37161 4.47602 4.40663C4.47935 4.47148 4.491 4.57304 4.47011 4.63014C4.50477 5.73869 4.52926 7.37998 4.47277 8.47402C4.47914 8.53726 4.49478 8.63703 4.48015 8.69487C6.52067 8.8156 8.81129 8.6622 10.8741 8.73886C10.9067 10.0161 10.9269 11.7943 10.8809 13.0738C8.91015 13.2295 6.50525 13.0238 4.47188 13.1369C4.47661 13.2056 4.4903 13.3077 4.46952 13.3687C4.50565 14.4103 4.52334 16.0974 4.47513 17.1432L4.48842 17.3416C4.92992 17.4348 6.22159 17.4012 6.73735 17.4004L10.6232 17.3942C11.3704 17.3932 12.2867 17.3668 13.0153 17.4272C13.0649 18.8228 13.0311 20.3622 13.0274 21.7683H0V0H13.0298Z" fill="#C2BFB9"/>
|
||||
<path d="M28.9816 0C29.6882 0.472806 30.3169 0.809757 30.4166 1.78273C30.5559 3.14391 30.5099 5.12562 30.4644 6.50056C29.5433 6.55432 27.0931 6.59113 26.2083 6.48786C26.1383 6.55342 26.1721 6.54075 26.0761 6.5419C25.9394 6.30164 25.9933 4.72191 26.0566 4.41106C25.0069 4.34402 22.8858 4.37764 21.7937 4.4087C21.6985 5.48716 21.767 7.57495 21.7733 8.71672C23.3169 8.73062 24.8608 8.73205 26.4044 8.72085C27.8463 8.71837 29.7895 8.36939 30.3563 10.1581C30.4227 10.4794 30.4805 10.843 30.4827 11.1712C30.4998 13.7868 30.4908 16.4035 30.4871 19.0192C30.4866 19.3602 30.4337 19.6526 30.4033 19.9794C30.3023 21.063 29.7051 21.1984 28.9943 21.7683H18.9183C18.8277 21.6461 18.7602 21.6535 18.6522 21.5741C17.9823 21.0811 17.6231 20.832 17.4326 20.032C17.3996 19.8865 17.3935 19.8353 17.3003 19.7157C17.2138 18.6643 17.2794 16.6781 17.2867 15.594C17.2612 15.4679 17.2567 15.3394 17.3198 15.2326L17.4169 15.4618L17.478 15.4889L17.5471 15.1889C18.9397 15.2079 20.3325 15.2037 21.7249 15.1759C21.8084 15.9193 21.7461 16.6513 21.7775 17.3841C22.2776 17.3946 25.7281 17.4316 26.082 17.3706C25.9175 16.2884 26.1722 14.199 26.0188 13.0894C24.7334 13.1336 23.4596 13.123 22.1082 13.1151C21.0343 13.1088 19.1306 13.2867 18.1955 12.6202C17.9119 12.4182 17.4843 11.7548 17.4633 11.4065C17.4064 11.2039 17.2645 11.0063 17.2908 10.8145C17.2246 8.97126 17.3079 7.0791 17.2764 5.23098C17.2595 4.24575 17.2648 3.21037 17.2929 2.22649C17.2951 2.24676 17.4457 1.70297 17.4571 1.65843C17.4876 1.59418 17.5087 1.43756 17.5781 1.30029C17.8952 0.672774 18.3737 0.41943 18.8527 0H28.9816Z" fill="#C2BFB9"/>
|
||||
<path d="M46.4198 0C47.6718 0.899999 47.8824 1.08409 47.7918 2.72901C47.731 3.83475 47.8975 5.22667 47.7691 6.30865L47.7328 6.48048C47.5838 6.54671 47.2595 6.56281 47.0971 6.55312C45.9297 6.48345 44.7504 6.58741 43.591 6.53451C43.5603 6.5326 43.5093 6.32802 43.4977 6.29064C43.5445 6.12646 43.5402 5.29844 43.5414 5.07184L43.5405 4.90797C43.5395 4.73649 43.5308 4.55004 43.4534 4.42464C43.3127 4.3605 39.6586 4.39856 39.2372 4.42022C39.2346 5.54116 39.3017 7.62032 39.2227 8.65767C39.8819 8.77575 40.8548 8.72141 41.5473 8.7306C43.044 8.75047 44.5873 8.68661 46.0811 8.74152C48.0574 9.07225 47.7898 10.6883 47.7912 12.2335L47.7927 15.4922L47.7906 18.4807C47.7898 19.0263 47.8107 19.7717 47.7369 20.2956C47.6406 20.9796 46.8796 21.3767 46.4304 21.7683H36.3121C36.2027 21.6149 36.1134 21.6327 35.9738 21.5357C35.4292 21.1576 35.1345 20.9382 34.9687 20.2924C34.6904 19.5446 34.7691 16.1426 34.8167 15.1904C36.2579 15.1823 37.6992 15.1826 39.1404 15.1919C39.2267 15.6996 39.3276 16.8692 39.1858 17.3694C40.237 17.4621 42.3595 17.3889 43.5119 17.4104C43.5949 16.2224 43.5334 14.3279 43.5243 13.1009C41.5274 13.1176 39.5169 13.1161 37.5218 13.1107C36.7968 13.0849 36.0163 13.0516 35.4972 12.4773C35.0872 12.0412 34.8084 11.3444 34.8046 10.7424C34.786 7.85839 34.7995 4.96898 34.796 2.08477C34.7955 1.55989 35.288 0.746369 35.7131 0.426639C35.9237 0.268216 36.1019 0.195041 36.308 0H46.4198Z" fill="#C2BFB9"/>
|
||||
<path d="M65.3009 0C65.3014 1.3829 65.3416 2.98525 65.2911 4.35024C64.7422 4.42078 64.1999 4.39321 63.6492 4.40102C61.3208 4.43408 58.966 4.34497 56.6399 4.4152C56.5222 5.10504 56.5466 7.89558 56.6075 8.64675C58.371 8.91365 61.2956 8.57726 63.155 8.75451C63.1619 9.33031 63.2364 12.8048 63.09 12.9914C62.4245 13.2103 57.8158 13.0747 56.6399 13.1346C56.5319 13.8193 56.5356 16.6111 56.6157 17.339C57.2675 17.4335 58.3879 17.3993 59.0861 17.3983L63.2055 17.393C63.7303 17.3926 64.8035 17.3652 65.2864 17.4568C65.3277 18.8678 65.3055 20.3516 65.3032 21.7683H52.2658C52.2263 20.3012 52.2575 18.6949 52.2587 17.2176L52.2575 3.29826C52.2575 2.27844 52.2221 0.986496 52.2826 0H65.3009Z" fill="#C2BFB9"/>
|
||||
<path d="M69.709 15.9277C69.7095 16.8548 69.8347 20.9048 69.6665 21.5699L69.6585 21.7683H69.577C69.5012 19.154 69.5705 16.1626 69.5699 13.5128L69.5764 0H69.7169L69.709 15.9277Z" fill="#C2BFB9"/>
|
||||
<path d="M81.279 0C82.0553 0.525496 82.5295 0.826058 82.7116 1.85212C82.8281 2.50704 82.7847 3.43871 82.7824 4.12614L82.7721 7.6237L82.783 21.7683H78.3371C78.309 21.0093 78.3119 20.1751 78.3274 19.4155C78.4256 14.4355 78.2023 9.37025 78.3424 4.39689C77.0235 4.39391 75.3819 4.36073 74.0784 4.41726C73.9687 6.15742 74.0513 8.78053 74.0518 10.5718L74.0583 21.7683H69.8049C69.7745 20.9202 69.7819 20.0099 69.7922 19.1574C69.8674 12.7851 69.6826 6.36743 69.8049 0H81.279Z" fill="#C2BFB9"/>
|
||||
<path d="M98.6817 0C99.245 0.426776 100.072 0.950614 100.076 1.71305C100.082 2.69656 100.198 4.9897 100.101 5.87906C100.108 6.08496 100.138 6.32456 100.048 6.50617C99.3623 6.64648 96.5942 6.5453 95.7918 6.52743C95.7591 5.867 95.7855 5.10108 95.7614 4.40634C94.6379 4.39042 93.2518 4.37205 92.0758 4.40604L91.5862 4.42405L91.5157 4.78899L91.5234 4.90679C91.6004 6.42571 91.5939 17.0774 91.4593 17.3723C92.8277 17.4476 94.3649 17.3785 95.7505 17.3995C95.7999 16.7033 95.7632 15.8196 95.8556 15.1854C96.3432 15.1902 99.8544 15.1482 100.029 15.2796C100.318 16.4092 100.014 18.9486 100.065 20.2064C100.093 20.8885 98.912 21.4413 98.6817 21.7683H88.6216C88.5188 21.6378 87.6483 21.2069 87.4019 20.784C87.159 20.3662 87.1188 20.0112 87.1073 19.5173C87.0774 18.1858 87.0901 16.8285 87.0896 15.4969L87.0907 3.82617C87.089 1.84144 86.7898 1.18487 88.5549 0H98.6817Z" fill="#C2BFB9"/>
|
||||
<path d="M117.6 4.31363C117.046 4.47618 109.809 4.45517 108.864 4.39453C108.808 5.53308 108.812 7.56345 108.857 8.71702C110.286 8.73247 111.715 8.73471 113.144 8.72351C113.709 8.72259 114.886 8.69262 115.41 8.77223C115.43 9.38599 115.515 12.6365 115.34 12.9828C114.816 13.2169 109.892 13.0772 108.863 13.1343C108.813 14.2134 108.806 16.3023 108.861 17.3847C110.832 17.4316 112.839 17.3716 114.811 17.3915C115.45 17.398 117.074 17.346 117.6 17.4671V21.7683H104.542C104.501 21.3743 104.548 19.6198 104.549 19.1359V4.65465C104.548 3.29906 104.486 1.29018 104.57 0H117.6V4.31363Z" fill="#C2BFB9"/>
|
||||
<path d="M48.0177 10.366L48 10.4894C48.106 10.8938 48.0982 19.744 48.0038 20.0249C47.9833 20.0942 47.9735 20.1302 47.9409 20.1958L47.8721 20.1374C47.806 19.7354 47.8297 18.9655 47.8299 18.5327L47.8326 12.1983C47.832 11.8575 47.7421 10.4752 47.9374 10.2993L48.0177 10.366Z" fill="#C2BFB9"/>
|
||||
<path d="M48.0186 1.90172C48.1014 2.28871 48.0969 6.19803 48.0053 6.44033C47.9765 6.42888 47.9313 6.38877 47.9046 6.3677C47.8471 5.53577 47.9104 4.49595 47.8922 3.6437C47.8847 3.29234 47.8659 2.12203 47.9079 1.84975L47.997 1.82702L48.0186 1.90172Z" fill="#C2BFB9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
3
apps/website/public/icons/investors/pace-capital.svg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
18
apps/website/public/icons/investors/sapphire-ventures.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="114" height="23" viewBox="0 0 114 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M59.7261 19.7973C59.7261 21.2999 60.3252 22.0063 61.7621 22.0063C63.1991 22.0063 63.7905 21.2999 63.7905 19.7973V16.8824H64.7098V19.5511C64.7098 21.9979 63.6425 22.7617 61.7621 22.7617C59.8817 22.7617 58.8063 21.9979 58.8063 19.5511V16.8824H59.7261V19.7973Z" fill="#C2BFB9"/>
|
||||
<path d="M88.2206 16.7512C90.0927 16.7513 91.3324 17.5068 91.349 18.6973H90.4045C90.4127 17.9584 89.5177 17.5146 88.2696 17.5146C86.9233 17.5146 86.2004 17.835 86.2004 18.4097C86.2004 19.8713 91.4883 18.3443 91.4883 20.98C91.4881 22.154 90.1824 22.7617 88.2943 22.7617C86.3566 22.7616 85.125 21.9733 85.1494 20.8155H86.0853C86.0771 21.5463 86.9724 21.9978 88.2287 21.9978C89.7391 21.9896 90.5603 21.6778 90.5604 21.0209C90.5604 19.3295 85.2561 20.9633 85.2558 18.5167C85.2558 17.359 86.4717 16.7512 88.2206 16.7512Z" fill="#C2BFB9"/>
|
||||
<path d="M25.8672 22.3676L28.6832 16.8824H29.8165L26.7865 22.63H24.9393L21.9093 16.8824H23.0426L25.8672 22.3676Z" fill="#C2BFB9"/>
|
||||
<path d="M37.4926 17.6463H33.174V19.3624H37.4189V20.0602H33.174V21.8666H37.4926V22.63H32.2541V16.8824H37.4926V17.6463Z" fill="#C2BFB9"/>
|
||||
<path d="M46.0695 21.5134V16.8824H46.9889V22.63H46.0448L41.4633 18.0076V22.63H40.5435V16.8824H41.4794L46.0695 21.5134Z" fill="#C2BFB9"/>
|
||||
<path d="M56.2693 17.6463H53.3463V22.63H52.427V17.6463H49.5121V16.8824H56.2693V17.6463Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.3161 16.8824C73.5067 16.8824 74.1638 17.5231 74.1638 18.4017C74.1637 19.3212 73.4244 19.8468 72.3161 19.8468H71.7081C73.0301 19.937 73.5643 20.758 74.5821 22.63H73.5805C72.5131 20.7169 72.2256 20.1258 71.1338 20.1258H68.7694L68.777 22.63H67.8577V16.8824H72.3161ZM68.7694 19.3376H72.0285C72.8987 19.3376 73.2439 19.0175 73.244 18.5495C73.244 17.9255 72.8827 17.6544 72.0285 17.6544H68.7608L68.7694 19.3376Z" fill="#C2BFB9"/>
|
||||
<path d="M82.4683 17.6463H78.1492V19.3624H82.3942V20.0602H78.1492V21.8666H82.4683V22.63H77.2299V16.8824H82.4683V17.6463Z" fill="#C2BFB9"/>
|
||||
<path d="M6.82474 0C10.6948 4.35571e-05 12.5148 1.24639 12.5148 1.24639V3.24338C12.5148 3.24338 10.6948 1.65668 6.9683 1.65663C4.07267 1.65663 2.17477 2.33495 2.17477 3.61749C2.17519 7.145 13.5522 3.35015 13.5525 9.15639C13.5525 11.688 10.9865 13.041 6.95119 13.041C2.69336 13.041 0.0167388 11.2525 0 11.2413V9.33892C0 9.33892 3.01305 11.2367 6.75677 11.2385C9.99295 11.2385 11.5455 10.7091 11.5455 9.2486C11.5453 5.47073 0.14838 9.46271 0.148312 3.86753C0.148312 1.33587 3.03945 0 6.82474 0Z" fill="#C2BFB9"/>
|
||||
<path d="M113.4 1.95088H103.178V5.3516H111.851V7.061H103.178V11.1101H113.4V12.7677H101.186V0.293297H113.4V1.95088Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.5959 0.293297C44.1736 0.293325 45.7748 1.89222 45.7748 3.82142C45.7721 6.16157 43.9928 7.33195 41.5931 7.33195H35.2204V12.7667H33.2476V0.293297H41.5959ZM35.2175 5.62256H41.1476C42.9965 5.62256 43.7788 5.08577 43.7788 3.80954C43.7796 2.53432 42.9973 1.96799 41.1476 1.96799H35.2175V5.62256Z" fill="#C2BFB9"/>
|
||||
<path d="M65.2223 5.34162H74.1286V0.284741H76.1208V12.7577H74.1286V6.99778H65.2223V12.7577H63.2295V0.284741H65.2223V5.34162Z" fill="#C2BFB9"/>
|
||||
<path d="M81.6388 12.7577H79.6466V0.284741H81.6388V12.7577Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.0372 0.270955C96.6238 0.270955 98.0511 1.66293 98.0511 3.57138C98.051 5.56913 96.3736 6.71066 94.0372 6.71066L92.7171 6.72872V6.7639C95.589 6.95987 96.7305 8.69061 98.9424 12.7577H96.7125C94.3941 8.60131 93.5165 7.31681 91.1617 7.31674H87.1444L87.1611 12.7577H85.1627V0.270955H94.0372ZM87.1444 5.6045H93.4135C95.304 5.60445 96.0536 4.90875 96.0536 3.89177C96.0536 2.53619 95.2684 1.94711 93.4135 1.94707H87.1264L87.1444 5.6045Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.5869 0.284741C59.1648 0.284762 60.7658 1.88318 60.7658 3.81239C60.7633 6.15432 58.9847 7.32241 56.584 7.32245H50.2113V12.7568H48.2386V0.284741H56.5869ZM50.2113 5.61495H56.1415C57.9902 5.61495 58.7726 5.07773 58.7726 3.80241C58.7726 2.52713 57.9902 1.96087 56.1415 1.96086H50.2113V5.61495Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3638 12.7506H29.1187L27.2182 9.18919H18.8124L16.9076 12.7506H14.6458L17.5712 7.56773V7.55632H17.5783L21.6874 0.284741H24.3399L31.3638 12.7506ZM19.6847 7.55632H26.3468L23.0203 1.31675L19.6847 7.55632Z" fill="#C2BFB9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
19
apps/website/public/icons/investors/truarrow-partners.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="122" height="23" viewBox="0 0 122 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.0498 2.08858C19.7947 2.09487 16.472 2.12329 13.2206 2.08944L19.5173 14.6881C20.6762 17.0087 21.9537 19.455 23.0556 21.7839C22.537 22.0518 21.7501 22.4192 21.2755 22.7035H21.1798C19.1216 18.6948 17.1673 14.5864 15.1189 10.569C14.2784 8.92062 13.4613 7.16352 12.5891 5.54081L12.586 22.7035H10.477L10.4773 11.7188C10.4775 9.68092 10.5065 7.56743 10.4584 5.53586L1.879 22.7035H1.78627C1.37028 22.4447 0.456336 22.0205 0 21.7985V21.6561C0.298159 21.359 1.90123 17.9578 2.20881 17.3451L9.82774 2.08739C6.60428 2.14206 3.23535 2.09182 0 2.0922V0H23.0537L23.0498 2.08858Z" fill="#C2BFB9"/>
|
||||
<path d="M94.2751 16.0714C94.8228 16.0509 95.6125 16.4099 95.8079 16.974C95.859 17.1221 95.8464 17.3599 95.7353 17.4778C94.9441 17.4744 95.3563 17.2157 94.7999 16.742C94.4876 16.4761 93.7085 16.688 93.5059 16.9783C93.4182 17.1046 93.386 17.261 93.4152 17.4116C93.5741 18.2024 95.8371 17.9368 95.9443 19.0356C96.0388 20.0007 95.4829 20.4916 94.5864 20.6572C93.442 20.6721 92.873 20.2825 92.6806 19.144C93.7787 19.1232 93.1278 19.501 93.8006 19.9861C94.2084 20.2804 95.5531 19.9466 95.3158 19.1757C95.1965 18.7889 94.6712 18.6885 94.3185 18.5928C93.1658 18.3725 92.2635 17.7413 93.1024 16.5256C93.254 16.3062 93.6447 16.1662 93.9224 16.1013C94.0398 16.0869 94.1572 16.0769 94.2751 16.0714Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.03 16.125C88.0473 16.1227 89.6049 15.8493 90.0745 16.9793C90.2104 17.3105 90.2109 17.6818 90.076 18.0132C89.8962 18.4453 89.6243 18.6292 89.2175 18.7984L90.2889 20.6066C90.0818 20.6103 89.6828 20.6442 89.5274 20.5412L88.591 18.8506L87.6132 18.8521L87.6107 20.6034L87.03 20.6085V16.125ZM89.3618 16.9662C88.8692 16.6146 88.1866 16.7062 87.6137 16.7043V18.3109C88.2129 18.2761 88.895 18.3981 89.3535 18.0764C89.6078 17.7357 89.7646 17.2535 89.3618 16.9662Z" fill="#C2BFB9"/>
|
||||
<path d="M74.945 16.1241C75.3893 16.1093 75.4195 16.0599 75.6924 16.4364C76.4349 17.4613 77.1779 18.4863 77.9208 19.511C77.8838 18.4414 77.912 17.2058 77.9116 16.1238C78.0699 16.1213 78.3398 16.0998 78.4733 16.1611C78.5576 17.3417 78.4913 19.3624 78.4899 20.6053C78.3773 20.6083 78.2209 20.6266 78.1123 20.6022C77.8794 20.5501 75.7956 17.5604 75.516 17.1475C75.5374 17.5867 75.5457 20.2559 75.4946 20.5576C75.3523 20.6301 75.1131 20.6075 74.9455 20.6044L74.945 16.1241Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M63.5806 16.1239C64.5886 16.1281 66.1603 15.8447 66.6154 16.9954C66.7479 17.3335 66.7405 17.7104 66.5949 18.0429C66.4161 18.4445 66.1481 18.6404 65.752 18.7915L66.8117 20.606C66.553 20.6109 66.344 20.618 66.0848 20.59C65.7954 20.0237 65.4349 19.428 65.1304 18.8499L64.1569 18.853L64.1564 20.6057L63.5801 20.6027L63.5806 16.1239ZM65.9084 18.0546C66.8258 16.6747 64.9638 16.6908 64.1555 16.7031L64.1545 18.3049C64.7752 18.285 65.4324 18.402 65.9084 18.0546Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.7406 16.1269C52.5533 16.1266 53.6914 15.9686 54.3052 16.4693C54.5941 16.7039 54.7754 17.0454 54.8085 17.4159C54.9055 18.4316 54.1839 18.9529 53.2568 19.029L52.3268 19.0155C52.3268 19.364 52.3555 20.2741 52.3 20.5673C52.1752 20.6234 51.8907 20.6075 51.7402 20.6076L51.7406 16.1269ZM53.953 16.9717C53.4839 16.6501 52.8641 16.7142 52.3263 16.7152L52.3268 18.435C52.8451 18.4403 53.5789 18.5221 53.973 18.1933C54.2682 17.8073 54.4061 17.2823 53.953 16.9717Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5497 16.1283C58.6715 16.1251 59.0277 16.1108 59.0525 16.1591C59.5056 17.049 60.6432 19.8474 60.9526 20.6056L60.3222 20.6069C60.1088 20.0731 59.9032 19.5364 59.7054 18.9968L59.2557 18.9939L57.9637 18.9952L57.3439 20.6039L56.7155 20.6089L58.5497 16.1283ZM58.807 16.8857C58.6043 17.3937 58.4026 17.9099 58.1931 18.4144L58.9634 18.4048L59.4506 18.4026C59.3634 18.1838 58.9078 16.9853 58.807 16.8857Z" fill="#C2BFB9"/>
|
||||
<path d="M72.2907 16.1257L72.2888 16.7052L70.9475 16.7019V20.6017L70.3639 20.6083L70.3634 16.6974L69.0397 16.7083L69.0358 16.1253L72.2907 16.1257Z" fill="#C2BFB9"/>
|
||||
<path d="M81.6099 16.1299C82.3422 16.0911 83.4564 16.1258 84.2169 16.1259L84.2179 16.7058L82.1906 16.7014V18.0281C82.558 18.0232 83.8613 17.985 84.155 18.0495C84.2237 18.1934 84.2023 18.4296 84.1989 18.5961L82.1941 18.5926C82.1756 19.0168 82.1921 19.5898 82.1911 20.0252C82.8376 19.9896 83.5504 20.0059 84.2033 20.0093L84.2038 20.6012L81.6114 20.6039L81.6099 16.1299Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.048 2.01512C101.256 1.6316 104.166 3.92364 104.545 7.13201C104.924 10.3404 102.628 13.2476 99.4195 13.6222C96.2171 13.9961 93.3168 11.7055 92.9382 8.50345C92.5602 5.30137 94.8467 2.39791 98.048 2.01512ZM98.6867 3.10704C96.0788 3.1376 93.9916 5.28081 94.0306 7.88864C94.0691 10.4965 96.2186 12.5769 98.8266 12.5301C101.423 12.4836 103.492 10.3453 103.453 7.74895C103.415 5.15252 101.284 3.07662 98.6867 3.10704Z" fill="#C2BFB9"/>
|
||||
<path d="M56.334 2.23936L56.3354 7.25333C56.3374 8.25301 56.4163 9.6123 56.1474 10.5587C55.2582 13.6866 51.1053 14.6067 48.8433 12.3446C48.2999 11.7962 47.9137 11.1118 47.7254 10.3629C47.5005 9.45968 47.5651 7.80546 47.5654 6.80158L47.5691 2.24145L49.4849 2.24193L49.4795 8.18762C49.4776 9.16714 49.4898 10.3964 50.2425 11.0251C52.1474 12.6169 54.6429 11.2013 54.4202 8.74805C54.3774 8.27888 54.4144 7.46752 54.4139 7.02125L54.4149 2.23912L56.334 2.23936Z" fill="#C2BFB9"/>
|
||||
<path d="M64.2963 2.24198C64.4346 2.60897 64.5974 2.98453 64.7523 3.34541C66.1905 6.68834 67.541 10.0817 68.9997 13.4146C68.6109 13.4031 68.1783 13.4146 67.7866 13.4166C67.3427 12.3577 66.9111 11.1524 66.4823 10.0707C65.6556 8.00572 64.8181 5.94491 63.9704 3.88842C63.9002 3.7518 63.8032 3.46465 63.7462 3.31049C63.4787 4.19435 62.7523 5.83983 62.3714 6.78616C61.4749 8.9828 60.6101 11.1921 59.7765 13.4133C59.3853 13.4134 58.9824 13.4254 58.5907 13.432L63.1572 2.24117L64.2963 2.24198Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.6301 2.24031C72.8651 2.25193 74.1007 2.22964 75.3357 2.23708C75.7045 2.23929 76.0758 2.23642 76.4417 2.28679C76.8475 2.33928 77.2431 2.45454 77.6134 2.62845C79.856 3.69654 80.1371 6.73413 78.1517 8.17892C77.7108 8.49963 77.3459 8.63518 76.8344 8.81442C77.685 10.1576 78.7934 12.0261 79.568 13.4145C79.1047 13.4104 78.6414 13.4155 78.1786 13.4297L75.6593 8.91114C74.6878 8.9184 73.7168 8.91904 72.7453 8.91319L72.7472 13.4206C72.3969 13.4055 71.9843 13.4234 71.6291 13.4305L71.6301 2.24031ZM77.6153 3.93647C76.8548 3.20318 75.7304 3.2863 74.7623 3.29212C74.0948 3.29205 73.4059 3.31248 72.7453 3.29174L72.7482 7.88555C73.3845 7.8645 74.0242 7.87144 74.6605 7.8787C75.6919 7.89049 76.8388 7.97424 77.6534 7.19857C78.54 6.24858 78.5785 4.86534 77.6153 3.93647Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.6478 2.23698C86.2003 2.23632 86.9369 2.21451 87.465 2.2867C87.8694 2.33854 88.2636 2.45389 88.6324 2.6285C90.8706 3.69892 91.1668 6.75605 89.1347 8.20509C88.7079 8.50973 88.3523 8.64067 87.8578 8.8129L90.6016 13.422C90.149 13.4092 89.6477 13.4173 89.1917 13.4154L88.9598 13.012C88.342 11.7801 87.3681 10.1562 86.6729 8.91086C85.7024 8.92129 84.7319 8.92301 83.7609 8.91614L83.7584 13.413C83.3945 13.411 83.0145 13.4228 82.6491 13.4286L82.6476 2.24102L85.6478 2.23698ZM89.3369 5.58386C89.2385 3.46873 87.4743 3.19518 85.7711 3.29355C85.1918 3.32701 84.3348 3.30925 83.7604 3.29512L83.7619 7.88569C84.4386 7.85368 85.165 7.87691 85.8442 7.87818C86.9671 7.88032 87.6848 7.96626 88.6105 7.253C89.0821 6.7754 89.3676 6.24411 89.3369 5.58386Z" fill="#C2BFB9"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.6959 2.2405C38.0088 2.23962 41.1262 2.08233 42.1825 2.44885C43.071 2.75713 43.818 3.37736 44.2302 4.22851C44.6343 5.08458 44.6874 6.06475 44.3782 6.95954C44.0294 7.95825 43.3762 8.54797 42.4479 8.99488C43.2341 10.4347 44.1699 11.9925 44.9966 13.4126C44.2553 13.4093 43.5139 13.4123 42.7725 13.4216C42.6669 13.375 40.7449 9.78423 40.4665 9.2744C39.8363 9.29482 39.2057 9.29505 38.5756 9.27502L38.5742 13.4089C37.9485 13.4032 37.3228 13.4096 36.6973 13.4282L36.6959 2.2405ZM42.6604 5.66365C42.5015 3.61229 40.0352 4.19297 38.6077 4.11636C38.5295 4.47411 38.5793 6.90255 38.5753 7.43461C39.7802 7.38691 41.3058 7.66269 42.1911 6.95226C42.5306 6.55729 42.7023 6.20474 42.6604 5.66365Z" fill="#C2BFB9"/>
|
||||
<path d="M107.485 2.24083L110.416 11.7167C111.247 9.42164 112.937 4.86395 113.836 2.33171C113.871 2.31265 113.901 2.29626 113.926 2.28261C113.993 2.27922 114.106 2.26986 114.165 2.29626C114.177 2.30151 114.235 2.33516 114.238 2.34103C115.238 4.99759 116.839 9.22764 117.773 11.7282C118.689 8.61534 119.661 5.32554 120.647 2.24083L121.8 2.2406V2.32586C121.717 2.4931 121.511 3.18662 121.446 3.39579L118.316 13.4202C118.001 13.409 117.609 13.4225 117.287 13.4247C117 12.5099 116.52 11.3168 116.181 10.4001L114.048 4.62407C113.126 7.44773 111.905 10.5943 110.885 13.4162L109.871 13.4258C109.707 12.9473 109.552 12.452 109.402 11.9688C108.394 8.72176 107.293 5.49167 106.305 2.23965L107.485 2.24083Z" fill="#C2BFB9"/>
|
||||
<path d="M34.0608 2.2406L34.0592 4.08982L30.9074 4.08301L30.9068 13.4006L29.0442 13.4035L29.0456 4.07811L25.884 4.08896L25.8833 2.23907L34.0608 2.2406Z" fill="#C2BFB9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
3
apps/website/public/icons/node-union-2size-reverse.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="37" height="48" viewBox="0 0 37 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37 48H23.3135C17.0346 47.9999 13.3532 43.0774 15.0801 37.0127L16.7461 31.1621C17.458 28.6708 15.5955 26.1868 13.0205 26.1865C12.1061 26.1865 11.4659 26.9844 11.2399 27.8705L9.74121 33.748C8.5757 38.3031 4.21923 42 0 42V6H9.18164C13.4008 6.00006 15.8743 9.69691 14.7139 14.252L14.0983 16.6643C13.497 19.0203 13.9592 22.0946 16.3906 22.0947C18.125 22.0946 19.6625 20.9366 20.1396 19.2529L22.4951 10.9873H22.5107C24.2377 4.92254 30.7286 1.98986e-06 37 0V48Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 586 B |
3
apps/website/public/icons/node-union-2size.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="62" height="94.14" viewBox="0 0 62 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.9346 0C33.456 0.000149153 39.6242 8.20368 36.7305 18.3115L33.9385 28.0635C32.7454 32.2159 35.8674 36.3555 40.1826 36.3555C42.9814 36.3555 45.4493 34.5653 46.3311 31.9268L47.7129 27.002C49.4225 20.9287 55.812 16 62 16V64H48.5342C42.3461 64 38.7182 59.0713 40.4199 52.998L40.8398 51.5L40.8301 51.4922C42.0104 47.3146 38.8756 43.1751 34.5352 43.1748C31.6287 43.1748 29.0515 45.1048 28.252 47.9111L24.3047 61.6885H24.2793C21.3855 71.7964 10.5089 80 0 80V0H22.9346Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
1
apps/website/public/icons/os/apple.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#211927"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
|
||||
|
After Width: | Height: | Size: 534 B |
1
apps/website/public/icons/os/windows.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#211927"><path d="M3 5.548l7.546-1.03v7.287H3V5.548zm0 12.904l7.546 1.03v-7.177H3v6.147zm8.454 1.14L22 21v-8.695h-10.546v8.287zM11.454 4.408L22 3v8.695H11.454V4.408z"/></svg>
|
||||
|
After Width: | Height: | Size: 241 B |
BIN
apps/website/public/images/about/c-logo.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/website/public/images/about/c.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/website/public/images/about/team.webp
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/website/public/images/careers/hero.webp
Normal file
|
After Width: | Height: | Size: 648 KiB |
BIN
apps/website/public/images/careers/team0.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/website/public/images/careers/team1.webp
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
apps/website/public/images/careers/team2.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/careers/team3.webp
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:06.000
|
||||
AI-generated video showcasing Grok Imagine image generation capabilities
|
||||
BIN
apps/website/public/images/cloud/ai-models/grok-video.webm
Normal file
BIN
apps/website/public/images/cloud/ai-models/nano-banana-pro.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/website/public/images/cloud/ai-models/qwen-image-edit.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:05.000
|
||||
AI-generated video showcasing Seedance 2.0 video generation capabilities
|
||||
BIN
apps/website/public/images/cloud/ai-models/seedance-20.webm
Normal file
4
apps/website/public/images/cloud/ai-models/wan-22.vtt
Normal file
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:05.000
|
||||
AI-generated video showcasing Wan 2.2 image-to-video generation capabilities
|
||||
BIN
apps/website/public/images/cloud/ai-models/wan-22.webm
Normal file
BIN
apps/website/public/images/cloud/audience-creator.webp
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
apps/website/public/images/cloud/audience-team.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
110
apps/website/public/images/cloud/hero-cube.svg
Normal file
@@ -0,0 +1,110 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 900" fill="none">
|
||||
<!-- Background geometric lines -->
|
||||
<g stroke="#49378B" stroke-width="1.5" fill="none" opacity="0.4">
|
||||
<!-- Outer hexagonal frame layers -->
|
||||
<path d="M400 80 L600 200 L600 440 L400 560 L200 440 L200 200 Z" />
|
||||
<path d="M400 120 L570 220 L570 420 L400 520 L230 420 L230 220 Z" />
|
||||
<!-- Connector lines going up -->
|
||||
<line x1="300" y1="160" x2="300" y2="60" />
|
||||
<line x1="400" y1="120" x2="400" y2="20" />
|
||||
<line x1="500" y1="160" x2="500" y2="60" />
|
||||
<!-- Bottom platform layers -->
|
||||
<path d="M250 520 L550 520 L600 560 L600 600 L400 700 L200 600 L200 560 Z" opacity="0.3" />
|
||||
<path d="M280 620 L520 620 L560 650 L560 680 L400 760 L240 680 L240 650 Z" opacity="0.2" />
|
||||
<path d="M320 700 L480 700 L510 720 L510 740 L400 800 L290 740 L290 720 Z" opacity="0.15" />
|
||||
</g>
|
||||
|
||||
<!-- 3D Isometric cube cluster -->
|
||||
<g transform="translate(400, 380)">
|
||||
<!-- Back layer cubes (purple/dark) -->
|
||||
<!-- Top back -->
|
||||
<g transform="translate(0, -100)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
|
||||
</g>
|
||||
|
||||
<!-- Middle row - left back -->
|
||||
<g transform="translate(-70, -55)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
|
||||
</g>
|
||||
<!-- Middle row - right back -->
|
||||
<g transform="translate(70, -55)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
|
||||
</g>
|
||||
|
||||
<!-- Yellow accent cubes - front facing -->
|
||||
<!-- Top -->
|
||||
<g transform="translate(0, -65)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
|
||||
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
|
||||
</g>
|
||||
|
||||
<!-- Middle left yellow -->
|
||||
<g transform="translate(-70, -20)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
|
||||
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
|
||||
</g>
|
||||
<!-- Middle right yellow -->
|
||||
<g transform="translate(70, -20)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
|
||||
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
|
||||
</g>
|
||||
|
||||
<!-- Center purple -->
|
||||
<g transform="translate(0, -20)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
|
||||
</g>
|
||||
|
||||
<!-- Bottom row -->
|
||||
<g transform="translate(-70, 25)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
|
||||
</g>
|
||||
<g transform="translate(70, 25)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#49378B" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#5a45a0" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#3d2d75" />
|
||||
</g>
|
||||
|
||||
<!-- Front bottom yellow -->
|
||||
<g transform="translate(0, 25)">
|
||||
<polygon points="0,-40 35,-20 35,20 0,40 -35,20 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-40 35,-20 0,-5 -35,-20" fill="#f2ff59" />
|
||||
<polygon points="0,-5 35,-20 35,20 0,40" fill="#d4e04e" />
|
||||
<polygon points="0,-5 -35,-20 -35,20 0,40" fill="#e0ec50" />
|
||||
</g>
|
||||
|
||||
<!-- Outer corner yellow accents -->
|
||||
<g transform="translate(-105, 5)">
|
||||
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
|
||||
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
|
||||
<polygon points="0,-2 20,-12 20,12 0,25" fill="#d4e04e" />
|
||||
</g>
|
||||
<g transform="translate(105, 5)">
|
||||
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
|
||||
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
|
||||
<polygon points="0,-2 -20,-12 -20,12 0,25" fill="#e0ec50" />
|
||||
</g>
|
||||
<g transform="translate(0, -135)">
|
||||
<polygon points="0,-25 20,-12 20,12 0,25 -20,12 -20,-12" fill="#f2ff59" />
|
||||
<polygon points="0,-25 20,-12 0,-2 -20,-12" fill="#f2ff59" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Bottom arrow/chevron shape -->
|
||||
<path d="M340 780 L400 820 L460 780 L460 850 L400 890 L340 850 Z" fill="#211927" stroke="#49378B" stroke-width="1" opacity="0.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/website/public/images/contact/c-projection.png
Normal file
|
After Width: | Height: | Size: 866 KiB |
BIN
apps/website/public/images/contact/c-projection.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/customers/c-projection.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/customers/detail-big-image.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
apps/website/public/images/gallery/gallery.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/homepage/use-case-left-1.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/website/public/images/homepage/use-case-left-2.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/website/public/images/homepage/use-case-left-3.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/homepage/use-case-left-4.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/homepage/use-case-left-5.webp
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
apps/website/public/images/homepage/use-case-right-1.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/website/public/images/homepage/use-case-right-2.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/website/public/images/homepage/use-case-right-3.webp
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
apps/website/public/images/homepage/use-case-right-4.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/homepage/use-case-right-5.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
60
apps/website/src/components/about/CareersSection.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-auto mt-20 flex flex-col gap-12 p-2 lg:flex-row lg:items-stretch lg:gap-8"
|
||||
>
|
||||
<!-- Team photo -->
|
||||
<div class="aspect-video w-full overflow-hidden rounded-4xl lg:w-1/2">
|
||||
<img
|
||||
src="/images/about/team.webp"
|
||||
alt="Comfy team"
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Join text -->
|
||||
<div class="flex flex-col justify-between p-6 lg:w-1/2">
|
||||
<div>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.careers.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('about.careers.heading', locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<BrandButton
|
||||
:href="locale === 'zh-CN' ? '/zh-CN/careers' : '/careers'"
|
||||
:label="t('about.careers.cta', locale)"
|
||||
variant="solid"
|
||||
class-name="mt-8 self-start rounded-2xl"
|
||||
/>
|
||||
<p class="text-primary-warm-gray mt-6 text-sm">
|
||||
{{ t('about.careers.noRole', locale) }}
|
||||
<a
|
||||
href="mailto:hiring@comfy.org"
|
||||
class="text-primary-comfy-yellow hover:underline"
|
||||
>
|
||||
hiring@comfy.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
80
apps/website/src/components/about/HeroSection.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const sectionRef = ref<HTMLElement>()
|
||||
const logoRef = ref<HTMLElement>()
|
||||
const labelRef = ref<HTMLElement>()
|
||||
const headingRef = ref<HTMLElement>()
|
||||
const bodyRef = ref<HTMLElement>()
|
||||
const ctaRef = ref<HTMLElement>()
|
||||
const videoRef = ref<HTMLElement>()
|
||||
|
||||
useHeroAnimation({
|
||||
section: sectionRef,
|
||||
textEls: [labelRef, headingRef, bodyRef, ctaRef],
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="pt-12 lg:pt-20">
|
||||
<div
|
||||
class="flex flex-col items-center text-center lg:flex-row lg:items-start lg:text-left"
|
||||
>
|
||||
<!-- Graphic -->
|
||||
<div
|
||||
ref="logoRef"
|
||||
class="order-2 mt-8 w-full lg:order-1 lg:mt-0 lg:w-5/12"
|
||||
>
|
||||
<img
|
||||
src="/images/about/c.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div
|
||||
class="order-1 flex flex-col items-center lg:order-2 lg:w-7/12 lg:items-start lg:pt-24 lg:pl-12"
|
||||
>
|
||||
<span
|
||||
ref="labelRef"
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.hero.label', locale) }}
|
||||
</span>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
|
||||
>
|
||||
{{ t('about.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p ref="bodyRef" class="text-primary-warm-gray mt-6 max-w-sm text-base">
|
||||
{{ t('about.hero.body', locale) }}
|
||||
</p>
|
||||
<div ref="ctaRef" class="mt-8">
|
||||
<BrandButton
|
||||
:href="locale === 'zh-CN' ? '/zh-CN/careers' : '/careers'"
|
||||
:label="t('about.hero.cta', locale)"
|
||||
variant="outline"
|
||||
class-name="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video overlapping the hero graphic -->
|
||||
<div ref="videoRef" class="-mt-16 px-20 pb-40 lg:-mt-72">
|
||||
<VideoPlayer :locale />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
166
apps/website/src/components/about/OurValuesSection.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import NodeBadge from '../common/NodeBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type TranslationKey = Parameters<typeof t>[0]
|
||||
|
||||
const values: {
|
||||
segments: Array<{ text: string }>
|
||||
bodyKey: TranslationKey
|
||||
}[] = [
|
||||
{
|
||||
segments: [{ text: 'SHIP' }, { text: 'IT' }],
|
||||
bodyKey: 'about.values.card1.body'
|
||||
},
|
||||
{
|
||||
segments: [{ text: 'SHARE' }, { text: 'IT' }],
|
||||
bodyKey: 'about.values.card2.body'
|
||||
},
|
||||
{
|
||||
segments: [{ text: 'OPEN-SOURCE' }, { text: 'IT' }],
|
||||
bodyKey: 'about.values.card3.body'
|
||||
},
|
||||
{
|
||||
segments: [{ text: 'RESPECT' }, { text: 'THE CRAFT' }],
|
||||
bodyKey: 'about.values.card4.body'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto max-w-5xl text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.values.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('about.values.headingBefore', locale)
|
||||
}}<span class="text-primary-comfy-yellow">{{
|
||||
t('about.values.headingHighlight', locale)
|
||||
}}</span
|
||||
>{{ t('about.values.headingAfter', locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-16 max-w-5xl">
|
||||
<!-- Desktop layout -->
|
||||
<div class="hidden lg:block">
|
||||
<!-- Row 1: SHIP IT + SHARE IT -->
|
||||
<div class="flex items-center gap-0">
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex-1 rounded-3xl border p-8"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="values[0].segments"
|
||||
segment-class="lg:py-3"
|
||||
text-class="text-2xl lg:text-3xl"
|
||||
/>
|
||||
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
|
||||
{{ t(values[0].bodyKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex-1 rounded-3xl border p-8"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="values[1].segments"
|
||||
segment-class="lg:py-3"
|
||||
text-class="text-2xl lg:text-3xl"
|
||||
/>
|
||||
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
|
||||
{{ t(values[1].bodyKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connector line -->
|
||||
<div class="flex justify-end pr-12">
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
class="-my-1.5 rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: OPEN-SOURCE IT -->
|
||||
<div class="border-primary-comfy-yellow rounded-3xl border p-8">
|
||||
<NodeBadge
|
||||
:segments="values[2].segments"
|
||||
segment-class="px-3"
|
||||
text-class="text-2xl lg:text-3xl"
|
||||
/>
|
||||
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
|
||||
{{ t(values[2].bodyKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connector line -->
|
||||
<div class="flex justify-start pl-24">
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
class="-my-1.5 rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: RESPECT THE CRAFT -->
|
||||
<div class="border-primary-comfy-yellow rounded-3xl border p-8">
|
||||
<NodeBadge
|
||||
:segments="values[3].segments"
|
||||
segment-class="px-3"
|
||||
text-class="text-2xl lg:text-3xl"
|
||||
/>
|
||||
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
|
||||
{{ t(values[3].bodyKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: stacked cards -->
|
||||
<div class="flex flex-col items-center lg:hidden">
|
||||
<template v-for="(value, i) in values" :key="value.segments[0].text">
|
||||
<div
|
||||
v-if="i > 0"
|
||||
class="flex w-full"
|
||||
:class="i % 2 === 1 ? 'justify-end pr-16' : 'justify-start pl-16'"
|
||||
>
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
class="-my-1 w-3 shrink-0 rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="border-primary-comfy-yellow w-full rounded-3xl border p-8"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="value.segments"
|
||||
segment-class="px-3"
|
||||
text-class="text-2xl lg:text-3xl"
|
||||
/>
|
||||
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
|
||||
{{ t(value.bodyKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
104
apps/website/src/components/about/StorySection.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const investors = [
|
||||
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
|
||||
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
|
||||
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
|
||||
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
|
||||
{ name: 'SAPPHIRE VENTURES', icon: '/icons/investors/sapphire-ventures.svg' },
|
||||
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<div class="mx-auto text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('about.story.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
|
||||
>
|
||||
{{ t('about.story.headingBefore', locale)
|
||||
}}<span class="text-primary-comfy-yellow">{{
|
||||
t('about.story.headingHighlight', locale)
|
||||
}}</span
|
||||
>{{ t('about.story.headingAfter', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
|
||||
{{ t('about.story.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Investor card -->
|
||||
<div
|
||||
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
|
||||
>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- OUR badge (shorter) -->
|
||||
<div class="relative z-10 flex h-9 items-center">
|
||||
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
|
||||
>
|
||||
OUR
|
||||
</span>
|
||||
</div>
|
||||
<!-- Union connector (overlaps both badges to eliminate seams) -->
|
||||
<img
|
||||
src="/icons/node-union-2size-reverse.svg"
|
||||
alt=""
|
||||
class="relative z-20 -mx-px h-12 w-auto"
|
||||
/>
|
||||
<!-- INVESTORS badge (taller) -->
|
||||
<div class="relative z-10 flex h-12 items-center">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
|
||||
>
|
||||
INVESTORS
|
||||
</span>
|
||||
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('about.story.investorsBody', locale) }}
|
||||
</p>
|
||||
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
|
||||
<div
|
||||
v-for="investor in investors"
|
||||
:key="investor.name"
|
||||
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
|
||||
>
|
||||
<img
|
||||
:src="investor.icon"
|
||||
:alt="investor.name"
|
||||
class="max-h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
|
||||
>
|
||||
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
|
||||
{{ t('about.quote.text', locale) }}
|
||||
</p>
|
||||
<p
|
||||
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
|
||||
>
|
||||
{{ t('about.quote.attribution', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
300
apps/website/src/components/about/ValuesSection.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type TranslationKey = Parameters<typeof t>[0]
|
||||
|
||||
const reasons: TranslationKey[] = [
|
||||
'about.careers.reason1',
|
||||
'about.careers.reason2',
|
||||
'about.careers.reason3',
|
||||
'about.careers.reason4'
|
||||
]
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const ifYouDotRef = ref<HTMLElement>()
|
||||
const reasonDots = ref<HTMLElement[]>([])
|
||||
const reasonOutputDotRef = ref<HTMLElement>()
|
||||
const comfyDotRef = ref<HTMLElement>()
|
||||
const wirePaths = ref<string[]>([])
|
||||
const comfyWirePath = ref('')
|
||||
|
||||
const mobileContainerRef = ref<HTMLElement>()
|
||||
const mobileIfYouDotRef = ref<HTMLElement>()
|
||||
const mobileReasonDots = ref<HTMLElement[]>([])
|
||||
const mobileOutputDotRef = ref<HTMLElement>()
|
||||
const mobileComfyDotRef = ref<HTMLElement>()
|
||||
const mobileWirePaths = ref<string[]>([])
|
||||
const mobileComfyWirePath = ref('')
|
||||
|
||||
function center(el: HTMLElement, container: DOMRect) {
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
x: r.left + r.width / 2 - container.left,
|
||||
y: r.top + r.height / 2 - container.top
|
||||
}
|
||||
}
|
||||
|
||||
function computeWires() {
|
||||
const c = containerRef.value
|
||||
const dot = ifYouDotRef.value
|
||||
if (!c || !dot) return
|
||||
|
||||
const cRect = c.getBoundingClientRect()
|
||||
const s = center(dot, cRect)
|
||||
|
||||
wirePaths.value = reasonDots.value.map((el) => {
|
||||
const e = center(el, cRect)
|
||||
const midX = s.x + (e.x - s.x) * 0.45
|
||||
return `M${s.x},${s.y} C${midX},${s.y} ${midX},${e.y} ${e.x},${e.y}`
|
||||
})
|
||||
|
||||
const outputDot = reasonOutputDotRef.value
|
||||
const comfyDot = comfyDotRef.value
|
||||
if (outputDot && comfyDot) {
|
||||
const s2 = center(outputDot, cRect)
|
||||
const e2 = center(comfyDot, cRect)
|
||||
const midX = s2.x + (e2.x - s2.x) * 0.5
|
||||
comfyWirePath.value = `M${s2.x},${s2.y} C${midX},${s2.y} ${midX},${e2.y} ${e2.x},${e2.y}`
|
||||
}
|
||||
}
|
||||
|
||||
function computeMobileWires() {
|
||||
const c = mobileContainerRef.value
|
||||
const dot = mobileIfYouDotRef.value
|
||||
if (!c || !dot) return
|
||||
|
||||
const cRect = c.getBoundingClientRect()
|
||||
const s = center(dot, cRect)
|
||||
|
||||
mobileWirePaths.value = mobileReasonDots.value.map((el, i) => {
|
||||
const e = center(el, cRect)
|
||||
const spread = (i + 1) * 14
|
||||
return `M${s.x},${s.y} C${s.x + spread},${s.y + 40} ${e.x + spread},${e.y - 40} ${e.x},${e.y}`
|
||||
})
|
||||
|
||||
const outputDot = mobileOutputDotRef.value
|
||||
const comfyDot = mobileComfyDotRef.value
|
||||
if (outputDot && comfyDot) {
|
||||
const s2 = center(outputDot, cRect)
|
||||
const e2 = center(comfyDot, cRect)
|
||||
const midY = s2.y + (e2.y - s2.y) * 0.5
|
||||
mobileComfyWirePath.value = `M${s2.x},${s2.y} C${s2.x},${midY} ${e2.x},${midY} ${e2.x},${e2.y}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
computeWires()
|
||||
computeMobileWires()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<!-- Desktop layout -->
|
||||
<div ref="containerRef" class="relative mx-auto hidden max-w-6xl lg:block">
|
||||
<!-- SVG wires overlay -->
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
|
||||
>
|
||||
<path
|
||||
v-for="(d, i) in wirePaths"
|
||||
:key="'wire-' + i"
|
||||
:d="d"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
v-if="comfyWirePath"
|
||||
:d="comfyWirePath"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex items-start gap-8">
|
||||
<!-- Left column: Why + IF YOU -->
|
||||
<div class="flex w-64 shrink-0 flex-col gap-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
|
||||
<p class="text-primary-warm-white text-2xl font-light">
|
||||
{{ t('about.careers.whyTitleBefore', locale) }}
|
||||
<br />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>{{ t('about.careers.whyTitleAfter', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
|
||||
>
|
||||
<span
|
||||
class="text-primary-warm-white text-xs font-bold tracking-wider"
|
||||
>
|
||||
{{ t('about.careers.whyLabel', locale) }}
|
||||
</span>
|
||||
<span
|
||||
ref="ifYouDotRef"
|
||||
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center column: Reasons card -->
|
||||
<div class="relative flex-1">
|
||||
<span
|
||||
ref="reasonOutputDotRef"
|
||||
class="bg-primary-comfy-yellow absolute top-1/3 right-0 z-20 size-3 translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
/>
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 px-10 py-8">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<span
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) reasonDots.push(el as HTMLElement)
|
||||
}
|
||||
"
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
|
||||
/>
|
||||
<p class="text-primary-warm-white text-base">
|
||||
{{ t(reason, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Comfy logo card -->
|
||||
<div
|
||||
class="w-64 shrink-0 rounded-3xl border border-white/10 bg-white/5 p-6"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
ref="comfyDotRef"
|
||||
class="bg-primary-comfy-ink relative z-10 size-1.5 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-ink h-4 w-20"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>
|
||||
</span>
|
||||
<img
|
||||
src="/images/about/c-logo.webp"
|
||||
alt="Comfy logo"
|
||||
class="mt-6 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div ref="mobileContainerRef" class="relative mx-auto max-w-6xl lg:hidden">
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
|
||||
>
|
||||
<path
|
||||
v-for="(d, i) in mobileWirePaths"
|
||||
:key="'m-wire-' + i"
|
||||
:d="d"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
v-if="mobileComfyWirePath"
|
||||
:d="mobileComfyWirePath"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
|
||||
<p class="text-primary-warm-white text-2xl font-light">
|
||||
{{ t('about.careers.whyTitleBefore', locale) }}
|
||||
<br />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>{{ t('about.careers.whyTitleAfter', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-3 flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
|
||||
>
|
||||
<span class="text-primary-warm-white text-xs font-bold tracking-wider">
|
||||
{{ t('about.careers.whyLabel', locale) }}
|
||||
</span>
|
||||
<span
|
||||
ref="mobileIfYouDotRef"
|
||||
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mt-12 rounded-3xl border border-white/10 bg-white/5 p-8"
|
||||
>
|
||||
<span
|
||||
ref="mobileOutputDotRef"
|
||||
class="bg-primary-comfy-yellow absolute right-1/3 bottom-0 z-20 size-3 translate-y-1/2 rounded-full"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
<p class="text-primary-warm-white text-base">
|
||||
{{ t(reason, locale) }}
|
||||
</p>
|
||||
<span
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) mobileReasonDots.push(el as HTMLElement)
|
||||
}
|
||||
"
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 rounded-3xl border border-white/10 bg-white/5 p-6">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
ref="mobileComfyDotRef"
|
||||
class="bg-primary-comfy-ink size-1.5 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-ink h-4 w-20"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>
|
||||
</span>
|
||||
<img
|
||||
src="/images/about/c-logo.webp"
|
||||
alt="Comfy logo"
|
||||
class="mt-6 w-full max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
40
apps/website/src/components/careers/HeroSection.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pt-20 pb-16 md:pt-28 md:pb-24">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('careers.hero.label', locale) }}
|
||||
</span>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line md:text-6xl"
|
||||
>
|
||||
{{ t('careers.hero.heading', locale) }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mx-auto mt-12 max-w-3xl p-2 md:mt-16"
|
||||
>
|
||||
<img
|
||||
src="/images/careers/hero.webp"
|
||||
alt="Comfy team"
|
||||
class="w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<div class="text-primary-comfy-canvas space-y-6 p-8 text-base/relaxed">
|
||||
<p>{{ t('careers.hero.body1', locale) }}</p>
|
||||
<p>{{ t('careers.hero.body2', locale) }}</p>
|
||||
<p>{{ t('careers.hero.body3', locale) }}</p>
|
||||
<p>{{ t('careers.hero.body4', locale) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
183
apps/website/src/components/careers/RolesSection.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
interface Role {
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
id: string
|
||||
}
|
||||
|
||||
interface Department {
|
||||
name: string
|
||||
key: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
const departments: Department[] = [
|
||||
{
|
||||
name: 'ENGINEERING',
|
||||
key: 'engineering',
|
||||
roles: [
|
||||
{
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: 'abc787b9-ad85-421c-8218-debd23bea096'
|
||||
},
|
||||
{
|
||||
title: 'Software Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
|
||||
},
|
||||
{
|
||||
title: 'Product Manager',
|
||||
department: 'Engineering',
|
||||
location: 'London, UK',
|
||||
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
|
||||
},
|
||||
{
|
||||
title: 'Tech Lead Manager, Frontend',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'DESIGN',
|
||||
key: 'design',
|
||||
roles: [
|
||||
{
|
||||
title: 'Creative Director',
|
||||
department: 'Design',
|
||||
location: 'San Francisco',
|
||||
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
|
||||
},
|
||||
{
|
||||
title: 'Graphic Designer',
|
||||
department: 'Design',
|
||||
location: 'London, UK',
|
||||
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
|
||||
},
|
||||
{
|
||||
title: 'Freelance Motion Designer',
|
||||
department: 'Design',
|
||||
location: 'Remote',
|
||||
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'MARKETING',
|
||||
key: 'marketing',
|
||||
roles: [
|
||||
{
|
||||
title: 'Lifecycle Growth Marketer',
|
||||
department: 'Marketing',
|
||||
location: 'San Francisco',
|
||||
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
|
||||
},
|
||||
{
|
||||
title: 'Graphic Designer',
|
||||
department: 'Marketing',
|
||||
location: 'London, UK',
|
||||
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...departments.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? departments
|
||||
: departments.filter((d) => d.key === activeCategory.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-20 md:px-20 md:py-32">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
|
||||
<!-- Left sidebar -->
|
||||
<div class="shrink-0 md:w-48">
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl"
|
||||
>
|
||||
{{ t('careers.roles.heading', locale) }}
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
class="mt-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role listings -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
v-for="dept in filteredDepartments"
|
||||
:key="dept.key"
|
||||
class="mb-12 last:mb-0"
|
||||
>
|
||||
<h3
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest"
|
||||
>
|
||||
{{ dept.name }}
|
||||
</h3>
|
||||
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
|
||||
>
|
||||
{{ role.title }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-3 text-sm">
|
||||
{{ role.department }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-4 flex shrink-0 items-center gap-3">
|
||||
<span class="text-primary-warm-gray text-sm">
|
||||
{{ role.location }}
|
||||
</span>
|
||||
<img
|
||||
src="/icons/arrow-up-right.svg"
|
||||
alt=""
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
60
apps/website/src/components/careers/TeamPhotosSection.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const photos = [
|
||||
{ src: '/images/careers/team0.webp', alt: 'Team dinner' },
|
||||
{ src: '/images/careers/team1.webp', alt: 'Team working' },
|
||||
{ src: '/images/careers/team2.webp', alt: 'Team offsite' },
|
||||
{ src: '/images/careers/team3.webp', alt: 'Team on a boat' }
|
||||
]
|
||||
|
||||
const loopedPhotos = [...photos, ...photos, ...photos]
|
||||
|
||||
const scrollRef = ref<HTMLElement>()
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el) return
|
||||
|
||||
const third = el.scrollWidth / 3
|
||||
const maxScroll = el.scrollWidth - el.clientWidth
|
||||
|
||||
if (el.scrollLeft >= maxScroll - 1) {
|
||||
el.scrollLeft -= third
|
||||
} else if (el.scrollLeft <= 1) {
|
||||
el.scrollLeft += third
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = scrollRef.value
|
||||
if (el) {
|
||||
el.scrollLeft = el.scrollWidth / 3
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-12 md:py-24">
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
|
||||
style="scrollbar-width: none"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(photo, i) in loopedPhotos"
|
||||
:key="i"
|
||||
class="aspect-3/4 h-64 shrink-0 md:h-96"
|
||||
>
|
||||
<img
|
||||
:src="photo.src"
|
||||
:alt="photo.alt"
|
||||
class="size-full rounded-2xl object-cover md:rounded-3xl"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
305
apps/website/src/components/careers/WhyJoinSection.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
type TranslationKey = Parameters<typeof t>[0]
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const reasons: TranslationKey[] = [
|
||||
'careers.whyJoin.reason1',
|
||||
'careers.whyJoin.reason2',
|
||||
'careers.whyJoin.reason3',
|
||||
'careers.whyJoin.reason4',
|
||||
'careers.whyJoin.reason5'
|
||||
]
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const ifYouDotRef = ref<HTMLElement>()
|
||||
const reasonDots = ref<HTMLElement[]>([])
|
||||
const reasonOutputDotRef = ref<HTMLElement>()
|
||||
const comfyDotRef = ref<HTMLElement>()
|
||||
const wirePaths = ref<string[]>([])
|
||||
const comfyWirePath = ref('')
|
||||
|
||||
const mobileContainerRef = ref<HTMLElement>()
|
||||
const mobileIfYouDotRef = ref<HTMLElement>()
|
||||
const mobileReasonDots = ref<HTMLElement[]>([])
|
||||
const mobileOutputDotRef = ref<HTMLElement>()
|
||||
const mobileComfyDotRef = ref<HTMLElement>()
|
||||
const mobileWirePaths = ref<string[]>([])
|
||||
const mobileComfyWirePath = ref('')
|
||||
|
||||
function center(el: HTMLElement, container: DOMRect) {
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
x: r.left + r.width / 2 - container.left,
|
||||
y: r.top + r.height / 2 - container.top
|
||||
}
|
||||
}
|
||||
|
||||
function computeWires() {
|
||||
const c = containerRef.value
|
||||
const dot = ifYouDotRef.value
|
||||
if (!c || !dot) return
|
||||
|
||||
const cRect = c.getBoundingClientRect()
|
||||
const s = center(dot, cRect)
|
||||
|
||||
wirePaths.value = reasonDots.value.map((el) => {
|
||||
const e = center(el, cRect)
|
||||
const midX = s.x + (e.x - s.x) * 0.45
|
||||
return `M${s.x},${s.y} C${midX},${s.y} ${midX},${e.y} ${e.x},${e.y}`
|
||||
})
|
||||
|
||||
const outputDot = reasonOutputDotRef.value
|
||||
const comfyDot = comfyDotRef.value
|
||||
if (outputDot && comfyDot) {
|
||||
const s2 = center(outputDot, cRect)
|
||||
const e2 = center(comfyDot, cRect)
|
||||
const midX = s2.x + (e2.x - s2.x) * 0.5
|
||||
comfyWirePath.value = `M${s2.x},${s2.y} C${midX},${s2.y} ${midX},${e2.y} ${e2.x},${e2.y}`
|
||||
}
|
||||
}
|
||||
|
||||
function computeMobileWires() {
|
||||
const c = mobileContainerRef.value
|
||||
const dot = mobileIfYouDotRef.value
|
||||
if (!c || !dot) return
|
||||
|
||||
const cRect = c.getBoundingClientRect()
|
||||
const s = center(dot, cRect)
|
||||
|
||||
mobileWirePaths.value = mobileReasonDots.value.map((el, i) => {
|
||||
const e = center(el, cRect)
|
||||
const spread = (i + 1) * 14
|
||||
return `M${s.x},${s.y} C${s.x + spread},${s.y + 40} ${e.x + spread},${e.y - 40} ${e.x},${e.y}`
|
||||
})
|
||||
|
||||
const outputDot = mobileOutputDotRef.value
|
||||
const comfyDot = mobileComfyDotRef.value
|
||||
if (outputDot && comfyDot) {
|
||||
const s2 = center(outputDot, cRect)
|
||||
const e2 = center(comfyDot, cRect)
|
||||
const midY = s2.y + (e2.y - s2.y) * 0.5
|
||||
mobileComfyWirePath.value = `M${s2.x},${s2.y} C${s2.x},${midY} ${e2.x},${midY} ${e2.x},${e2.y}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
computeWires()
|
||||
computeMobileWires()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-24 lg:px-20 lg:py-32">
|
||||
<!-- Desktop layout -->
|
||||
<div ref="containerRef" class="relative mx-auto hidden max-w-6xl lg:block">
|
||||
<!-- SVG wires overlay -->
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
|
||||
>
|
||||
<path
|
||||
v-for="(d, i) in wirePaths"
|
||||
:key="'wire-' + i"
|
||||
:d="d"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
v-if="comfyWirePath"
|
||||
:d="comfyWirePath"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex items-start gap-8">
|
||||
<!-- Left column: Why + IF YOU -->
|
||||
<div class="flex w-64 shrink-0 flex-col gap-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
|
||||
<p class="text-primary-warm-white text-2xl font-light">
|
||||
{{ t('about.careers.whyTitleBefore', locale) }}
|
||||
<br />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>{{ t('about.careers.whyTitleAfter', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
|
||||
>
|
||||
<span
|
||||
class="text-primary-warm-white text-xs font-bold tracking-wider"
|
||||
>
|
||||
{{ t('about.careers.whyLabel', locale) }}
|
||||
</span>
|
||||
<span
|
||||
ref="ifYouDotRef"
|
||||
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center column: Reasons card -->
|
||||
<div class="relative flex-1">
|
||||
<span
|
||||
ref="reasonOutputDotRef"
|
||||
class="bg-primary-comfy-yellow absolute top-1/3 right-0 z-20 size-3 translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
/>
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 px-10 py-8">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<span
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) reasonDots.push(el as HTMLElement)
|
||||
}
|
||||
"
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
|
||||
/>
|
||||
<p class="text-primary-warm-white text-base">
|
||||
{{ t(reason, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Team photo card -->
|
||||
<div
|
||||
class="w-64 shrink-0 rounded-3xl border border-white/10 bg-white/5 p-2"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
ref="comfyDotRef"
|
||||
class="bg-primary-comfy-ink size-1.5 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-ink h-4 w-20"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>
|
||||
</span>
|
||||
<img
|
||||
src="/images/about/team.webp"
|
||||
alt="Comfy team"
|
||||
class="mt-2 w-full rounded-2xl object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div ref="mobileContainerRef" class="relative mx-auto max-w-6xl lg:hidden">
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
|
||||
>
|
||||
<path
|
||||
v-for="(d, i) in mobileWirePaths"
|
||||
:key="'m-wire-' + i"
|
||||
:d="d"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
v-if="mobileComfyWirePath"
|
||||
:d="mobileComfyWirePath"
|
||||
stroke="#F2FF59"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
|
||||
<p class="text-primary-warm-white text-2xl font-light">
|
||||
{{ t('about.careers.whyTitleBefore', locale) }}
|
||||
<br />
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>{{ t('about.careers.whyTitleAfter', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-3 flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
|
||||
>
|
||||
<span class="text-primary-warm-white text-xs font-bold tracking-wider">
|
||||
{{ t('about.careers.whyLabel', locale) }}
|
||||
</span>
|
||||
<span
|
||||
ref="mobileIfYouDotRef"
|
||||
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mt-12 rounded-3xl border border-white/10 bg-white/5 p-8"
|
||||
>
|
||||
<span
|
||||
ref="mobileOutputDotRef"
|
||||
class="bg-primary-comfy-yellow absolute right-1/3 bottom-0 z-20 size-3 translate-y-1/2 rounded-full"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
<p class="text-primary-warm-white text-base">
|
||||
{{ t(reason, locale) }}
|
||||
</p>
|
||||
<span
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) mobileReasonDots.push(el as HTMLElement)
|
||||
}
|
||||
"
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 rounded-3xl border border-white/10 bg-white/5 p-2">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
ref="mobileComfyDotRef"
|
||||
class="bg-primary-comfy-ink size-1.5 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-ink h-4 w-20"
|
||||
style="mask: url(/icons/logo.svg) no-repeat center / contain"
|
||||
/>
|
||||
</span>
|
||||
<img
|
||||
src="/images/about/team.webp"
|
||||
alt="Comfy team"
|
||||
class="mt-2 w-full rounded-2xl object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -15,6 +15,10 @@ const meta: Meta<typeof BrandButton> = {
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['solid', 'outline']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'lg']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
@@ -38,13 +42,33 @@ export const Outline: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeSolid: Story = {
|
||||
args: {
|
||||
variant: 'solid',
|
||||
size: 'lg'
|
||||
}
|
||||
}
|
||||
|
||||
export const LargeOutline: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
size: 'lg'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { BrandButton },
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<BrandButton href="#" label="SOLID BUTTON" variant="solid" />
|
||||
<BrandButton href="#" label="OUTLINE BUTTON" variant="outline" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-4 items-center">
|
||||
<BrandButton href="#" label="SOLID SM" variant="solid" size="sm" />
|
||||
<BrandButton href="#" label="OUTLINE SM" variant="outline" size="sm" />
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<BrandButton href="#" label="SOLID LG" variant="solid" size="lg" />
|
||||
<BrandButton href="#" label="OUTLINE LG" variant="outline" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -5,13 +5,20 @@ const {
|
||||
href,
|
||||
label,
|
||||
variant = 'solid',
|
||||
size = 'sm',
|
||||
className = ''
|
||||
} = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
label?: string
|
||||
variant?: 'solid' | 'outline'
|
||||
size?: 'sm' | 'lg'
|
||||
className?: string
|
||||
}>()
|
||||
|
||||
const sizeClass =
|
||||
size === 'lg'
|
||||
? 'rounded-full px-8 py-4 text-sm font-bold tracking-wider'
|
||||
: 'rounded-2xl px-4 py-2 text-sm font-semibold'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +26,7 @@ const {
|
||||
:href="href"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-2xl px-4 py-2 text-sm font-semibold',
|
||||
sizeClass,
|
||||
className,
|
||||
variant === 'solid'
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink transition-opacity hover:opacity-90'
|
||||
@@ -27,6 +34,8 @@ const {
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
<span class="ppformula-text-center">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
59
apps/website/src/components/common/CategoryNav.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface CategoryItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const { categories, modelValue } = defineProps<{
|
||||
categories: CategoryItem[]
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex items-center gap-3 overflow-x-auto md:flex-col"
|
||||
aria-label="Category filter"
|
||||
>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
type="button"
|
||||
:aria-pressed="modelValue === category.value"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 cursor-pointer self-start text-xs font-semibold tracking-wide whitespace-nowrap transition-colors',
|
||||
modelValue === category.value
|
||||
? 'text-primary-comfy-ink'
|
||||
: 'text-primary-warm-gray hover:text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
@click="emit('update:modelValue', category.value)"
|
||||
>
|
||||
<span v-if="modelValue === category.value" class="relative inline-block">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow ppformula-text-center inline-flex items-center rounded-lg px-4 py-2"
|
||||
>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
<!-- Triangle pointer -->
|
||||
<span
|
||||
class="border-t-primary-comfy-yellow absolute bottom-0 left-4 translate-y-full border-x-[6px] border-t-[6px] border-x-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="bg-transparency-white-t4 ppformula-text-center inline-flex items-center rounded-lg px-4 py-2"
|
||||
>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
225
apps/website/src/components/common/ContentSection.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from './BrandButton.vue'
|
||||
import CategoryNav from './CategoryNav.vue'
|
||||
import { deriveSections } from '../../config/contentSections'
|
||||
|
||||
const {
|
||||
prefix,
|
||||
locale = 'en',
|
||||
readMoreHref
|
||||
} = defineProps<{
|
||||
prefix: string
|
||||
locale?: Locale
|
||||
readMoreHref?: string
|
||||
}>()
|
||||
|
||||
const sections = deriveSections(prefix)
|
||||
|
||||
function key(sectionId: string, suffix: string): TranslationKey {
|
||||
return `${prefix}.${sectionId}.${suffix}` as TranslationKey
|
||||
}
|
||||
|
||||
const categories = computed(() =>
|
||||
sections.map((s) => ({
|
||||
label: t(key(s.id, 'label'), locale),
|
||||
value: s.id
|
||||
}))
|
||||
)
|
||||
|
||||
const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
let isScrolling = false
|
||||
|
||||
onMounted(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
activeSection.value = entry.target.id
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
for (const section of sections) {
|
||||
const el = document.getElementById(section.id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
isScrolling = true
|
||||
const el = document.getElementById(id)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
setTimeout(() => {
|
||||
isScrolling = false
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<!-- Desktop sticky nav -->
|
||||
<aside class="hidden lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<CategoryNav
|
||||
:categories="categories"
|
||||
:model-value="activeSection"
|
||||
@update:model-value="scrollToSection"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:id="section.id"
|
||||
:key="section.id"
|
||||
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
|
||||
>
|
||||
<h2
|
||||
v-if="section.hasTitle"
|
||||
class="text-primary-comfy-canvas mb-6 text-2xl font-light"
|
||||
>
|
||||
{{ t(key(section.id, 'title'), locale) }}
|
||||
</h2>
|
||||
|
||||
<template v-for="(block, i) in section.blocks" :key="i">
|
||||
<!-- Paragraph -->
|
||||
<p
|
||||
v-if="block.type === 'paragraph'"
|
||||
class="text-primary-comfy-canvas mt-4 text-sm/relaxed"
|
||||
v-html="t(key(section.id, `block.${i}`), locale)"
|
||||
/>
|
||||
|
||||
<!-- Heading (h3) -->
|
||||
<h3
|
||||
v-else-if="block.type === 'heading'"
|
||||
class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic"
|
||||
>
|
||||
{{ t(key(section.id, `block.${i}.heading`), locale) }}
|
||||
</h3>
|
||||
|
||||
<!-- Bullet list -->
|
||||
<ul
|
||||
v-else-if="block.type === 'list'"
|
||||
class="mt-4 space-y-1 pl-5 text-sm"
|
||||
>
|
||||
<li
|
||||
v-for="(item, j) in t(
|
||||
key(section.id, `block.${i}`),
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="text-primary-comfy-canvas flex items-start gap-2"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow mt-1.5 size-1.5 shrink-0 rounded-full"
|
||||
/>
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ordered list -->
|
||||
<ol
|
||||
v-else-if="block.type === 'ordered-list'"
|
||||
class="mt-4 space-y-1 pl-1 text-sm"
|
||||
>
|
||||
<li
|
||||
v-for="(item, j) in t(
|
||||
key(section.id, `block.${i}.ol`),
|
||||
locale
|
||||
).split('\n')"
|
||||
:key="j"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow shrink-0 font-semibold tabular-nums"
|
||||
>
|
||||
{{ String(j + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
{{ item }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Image with caption -->
|
||||
<figure v-else-if="block.type === 'image'" class="my-8">
|
||||
<img
|
||||
:src="t(key(section.id, `block.${i}.src`), locale)"
|
||||
:alt="t(key(section.id, `block.${i}.alt`), locale)"
|
||||
class="w-full rounded-2xl object-cover"
|
||||
/>
|
||||
<figcaption class="text-primary-comfy-canvas mt-3 text-xs">
|
||||
{{ t(key(section.id, `block.${i}.caption`), locale) }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- Blockquote -->
|
||||
<blockquote
|
||||
v-else-if="block.type === 'blockquote'"
|
||||
:class="
|
||||
cn(
|
||||
'border-primary-comfy-yellow my-8 rounded-2xl border-l-4 p-8',
|
||||
'bg-(--site-bg-soft)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas text-lg/relaxed font-light italic"
|
||||
>
|
||||
"{{ t(key(section.id, `block.${i}.text`), locale) }}"
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">
|
||||
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
||||
</p>
|
||||
</blockquote>
|
||||
|
||||
<!-- Author card -->
|
||||
<div
|
||||
v-else-if="block.type === 'author'"
|
||||
:class="cn('mt-8 rounded-2xl p-6', 'bg-(--site-bg-soft)')"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(key(section.id, `block.${i}.label`), locale) }}
|
||||
</span>
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm font-semibold">
|
||||
{{ t(key(section.id, `block.${i}.name`), locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(key(section.id, `block.${i}.role`), locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Read more CTA -->
|
||||
<div v-if="readMoreHref" class="mt-8 flex justify-center">
|
||||
<BrandButton :href="readMoreHref" variant="solid" size="lg">
|
||||
<span class="ppformula-text-center flex items-center gap-2">
|
||||
{{ t('customers.story.readMore' as TranslationKey, locale) }}
|
||||
<span class="text-base">↗</span>
|
||||
</span>
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
32
apps/website/src/components/common/FAQSection.stories.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import FAQSection from './FAQSection.vue'
|
||||
|
||||
const meta: Meta<typeof FAQSection> = {
|
||||
title: 'Website/Common/FAQSection',
|
||||
component: FAQSection,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
headingKey: 'download.faq.heading',
|
||||
faqPrefix: 'download.faq',
|
||||
faqCount: 3
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const ManyItems: Story = {
|
||||
args: {
|
||||
headingKey: 'download.faq.heading',
|
||||
faqPrefix: 'download.faq',
|
||||
faqCount: 8
|
||||
}
|
||||
}
|
||||
109
apps/website/src/components/common/FAQSection.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
faqPrefix,
|
||||
faqCount
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
faqPrefix: string
|
||||
faqCount: number
|
||||
}>()
|
||||
|
||||
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
|
||||
{ length: faqCount },
|
||||
(_, i) => ({
|
||||
q: `${faqPrefix}.${i + 1}.q` as TranslationKey,
|
||||
a: `${faqPrefix}.${i + 1}.a` as TranslationKey
|
||||
})
|
||||
)
|
||||
|
||||
const faqs = computed(() =>
|
||||
faqKeys.map(({ q, a }) => ({
|
||||
question: t(q, locale),
|
||||
answer: t(a, locale)
|
||||
}))
|
||||
)
|
||||
|
||||
const expanded = reactive(faqKeys.map(() => false))
|
||||
|
||||
function toggle(index: number) {
|
||||
expanded[index] = !expanded[index]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-24 md:px-20 md:py-40">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="index"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${index}`"
|
||||
type="button"
|
||||
:aria-expanded="expanded[index]"
|
||||
:aria-controls="`faq-panel-${index}`"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-between text-left',
|
||||
index === 0 ? 'pb-6' : 'py-6'
|
||||
)
|
||||
"
|
||||
@click="toggle(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-light md:text-xl',
|
||||
expanded[index]
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ faq.question }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ expanded[index] ? '−' : '+' }}
|
||||
</span>
|
||||
</button>
|
||||
<section
|
||||
v-show="expanded[index]"
|
||||
:id="`faq-panel-${index}`"
|
||||
role="region"
|
||||
:aria-labelledby="`faq-trigger-${index}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -109,7 +109,7 @@ onUnmounted(() => {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 md:hidden"
|
||||
class="bg-primary-comfy-ink fixed inset-0 z-40 flex flex-col px-6 pt-24 pb-8 lg:hidden"
|
||||
>
|
||||
<!-- Main list -->
|
||||
<template v-if="!activeSection">
|
||||
@@ -173,7 +173,9 @@ onUnmounted(() => {
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-xs font-bold"
|
||||
>
|
||||
<span class="inline-block skew-x-12">{{ item.badge }}</span>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
|
||||
@@ -81,7 +81,9 @@ const emit = defineEmits<{
|
||||
v-if="item.badge"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink -skew-x-12 rounded-sm px-1 py-0.5 text-[9px]/3 leading-none font-bold"
|
||||
>
|
||||
<span class="inline-block skew-x-12">{{ item.badge }}</span>
|
||||
<span class="ppformula-text-center inline-block skew-x-12">{{
|
||||
item.badge
|
||||
}}</span>
|
||||
</span>
|
||||
<img
|
||||
v-if="item.external"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const { segments, segmentClass = 'px-6' } = defineProps<{
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
segments,
|
||||
segmentClass = 'px-6',
|
||||
textClass = 'text-2xl sm:text-3xl lg:text-5xl'
|
||||
} = defineProps<{
|
||||
segments: Array<{ text?: string; logoSrc?: string; logoAlt?: string }>
|
||||
segmentClass?: string
|
||||
textClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="font-formula-condensed flex h-9 items-stretch font-semibold lg:h-auto"
|
||||
class="font-formula-condensed flex h-11 items-stretch font-semibold **:select-none sm:h-auto"
|
||||
>
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
@@ -28,18 +35,27 @@ const { segments, segmentClass = 'px-6' } = defineProps<{
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center justify-center py-1 lg:py-5"
|
||||
:class="segmentClass"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-yellow text-primary-comfy-ink flex items-center justify-center py-1.5 transition-all duration-300 sm:py-3 lg:py-5',
|
||||
segmentClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="segment.logoSrc"
|
||||
:src="segment.logoSrc"
|
||||
:alt="segment.logoAlt ?? ''"
|
||||
class="inline-block h-4 brightness-0 lg:h-10"
|
||||
class="inline-block h-5 brightness-0 transition-all duration-300 sm:h-7 lg:h-10"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block translate-y-0.5 text-xl font-bold whitespace-nowrap lg:translate-y-1 lg:text-5xl"
|
||||
:class="
|
||||
cn(
|
||||
'inline-block translate-y-1 font-bold whitespace-nowrap transition-all duration-300',
|
||||
textClass
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ segment.text }}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -11,8 +13,12 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<template>
|
||||
<a
|
||||
:href="href"
|
||||
class="flex flex-col justify-between rounded-2xl p-8 transition-opacity hover:opacity-90"
|
||||
:class="bg"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col justify-between rounded-2xl p-8 transition-opacity hover:opacity-90',
|
||||
bg
|
||||
)
|
||||
"
|
||||
>
|
||||
<h3 class="text-3xl font-light whitespace-pre-line text-white lg:text-4xl">
|
||||
{{ title }}
|
||||
|
||||
79
apps/website/src/components/common/ProductCardsSection.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import ProductCard from './ProductCard.vue'
|
||||
|
||||
type Product = 'local' | 'cloud' | 'api' | 'enterprise'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
excludeProduct,
|
||||
labelKey = ''
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
excludeProduct?: Product
|
||||
labelKey?: TranslationKey
|
||||
}>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
function cardDef(product: Product, href: string, bg: string) {
|
||||
return {
|
||||
product,
|
||||
title: t(`products.${product}.title`, locale),
|
||||
description: t(`products.${product}.description`, locale),
|
||||
cta: t(`products.${product}.cta`, locale),
|
||||
href,
|
||||
bg
|
||||
}
|
||||
}
|
||||
|
||||
const allCards: (ReturnType<typeof cardDef> & { product: Product })[] = [
|
||||
cardDef('local', routes.download, 'bg-primary-warm-gray'),
|
||||
cardDef('cloud', routes.cloud, 'bg-secondary-mauve'),
|
||||
cardDef('api', routes.api, 'bg-primary-comfy-plum'),
|
||||
cardDef('enterprise', routes.cloudEnterprise, 'bg-illustration-forest')
|
||||
]
|
||||
|
||||
const cards = excludeProduct
|
||||
? allCards.filter((c) => c.product !== excludeProduct)
|
||||
: allCards
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<p
|
||||
v-if="labelKey"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(labelKey, locale) }}
|
||||
</p>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t('products.heading', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-comfy-canvas/70 mt-4 text-sm">
|
||||
{{ t('products.subheading', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mt-16 grid grid-cols-1 gap-4',
|
||||
cards.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ProductCard v-for="card in cards" :key="card.product" v-bind="card" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
56
apps/website/src/components/common/ProductHeroBadge.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
logoSrc = '/icons/logo.svg',
|
||||
logoAlt = 'Comfy',
|
||||
text = 'LOCAL'
|
||||
} = defineProps<{
|
||||
logoSrc?: string
|
||||
logoAlt?: string
|
||||
text?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="font-formula-condensed flex items-stretch font-semibold">
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
|
||||
>
|
||||
<img
|
||||
:src="logoSrc"
|
||||
:alt="logoAlt"
|
||||
class="inline-block h-6 brightness-0 lg:h-10"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<img
|
||||
src="/icons/node-union-2size.svg"
|
||||
alt=""
|
||||
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
|
||||
>
|
||||
<span
|
||||
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px my-auto h-7.25 self-center lg:h-15.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,9 +21,8 @@ useFrameScrub(canvasRef, {
|
||||
scrollTrigger: (canvas) => ({
|
||||
trigger: canvas,
|
||||
start: 'top bottom',
|
||||
endTrigger: footerRef.value,
|
||||
end: 'bottom bottom',
|
||||
scrub: true
|
||||
end: 'top bottom',
|
||||
scrub: 1
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
@@ -86,7 +87,7 @@ const ctaButtons = [
|
||||
},
|
||||
{
|
||||
label: t('nav.launchCloud', locale),
|
||||
href: externalLinks.app,
|
||||
href: externalLinks.cloud,
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
@@ -126,7 +127,7 @@ let mq: MediaQueryList
|
||||
|
||||
onMounted(() => {
|
||||
currentPath.value = window.location.pathname
|
||||
mq = window.matchMedia('(min-width: 768px)')
|
||||
mq = window.matchMedia('(min-width: 1024px)')
|
||||
mq.addEventListener('change', onMediaChange)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
document.addEventListener('astro:after-swap', onNavigate)
|
||||
@@ -149,22 +150,22 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between px-6 py-5 md:px-20 md:py-8"
|
||||
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between px-6 py-5 lg:px-10 lg:py-8 xl:px-20"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a :href="routes.home" aria-label="Comfy home">
|
||||
<img src="/icons/logomark.svg" alt="Comfy" class="h-8 md:hidden" />
|
||||
<img src="/icons/logomark.svg" alt="Comfy" class="h-8 lg:hidden" />
|
||||
<img
|
||||
src="/icons/logo.svg"
|
||||
alt="Comfy"
|
||||
class="hidden h-10 w-36 object-contain object-left md:block"
|
||||
class="hidden h-10 w-36 object-contain object-left lg:block"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div
|
||||
data-testid="desktop-nav-links"
|
||||
class="hidden items-center gap-10 md:flex"
|
||||
class="hidden items-center gap-4 lg:flex xl:gap-10"
|
||||
>
|
||||
<NavDesktopLink
|
||||
v-for="link in navLinks"
|
||||
@@ -181,7 +182,7 @@ onUnmounted(() => {
|
||||
<!-- Desktop CTA buttons -->
|
||||
<div
|
||||
data-testid="desktop-nav-cta"
|
||||
class="hidden items-center gap-2 md:flex"
|
||||
class="hidden items-center gap-2 lg:flex"
|
||||
>
|
||||
<BrandButton
|
||||
v-for="cta in ctaButtons"
|
||||
@@ -196,11 +197,13 @@ onUnmounted(() => {
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
ref="hamburgerRef"
|
||||
class="flex size-10 items-center justify-center rounded-xl md:hidden"
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-xl lg:hidden',
|
||||
mobileMenuOpen
|
||||
? 'border-primary-comfy-yellow border-2 bg-transparent'
|
||||
: 'bg-primary-comfy-yellow'
|
||||
)
|
||||
"
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
aria-controls="site-mobile-menu"
|
||||
|
||||
@@ -14,8 +14,11 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,7 +26,7 @@ const row2 = logos.slice(6)
|
||||
<!-- Single row on desktop -->
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div
|
||||
v-for="(logo, i) in [...logos, ...logos]"
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
@@ -38,7 +41,7 @@ const row2 = logos.slice(6)
|
||||
>
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in [...row1, ...row1]"
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
@@ -47,7 +50,7 @@ const row2 = logos.slice(6)
|
||||
</div>
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in [...row2, ...row2]"
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
79
apps/website/src/components/common/VideoPlayer.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
|
||||
>
|
||||
<div class="size-full" />
|
||||
|
||||
<!-- Bottom control bar -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 lg:px-6 lg:py-5"
|
||||
>
|
||||
<!-- Play button -->
|
||||
<button
|
||||
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-full lg:size-10"
|
||||
:aria-label="locale === 'zh-CN' ? '播放' : 'Play'"
|
||||
>
|
||||
<svg class="ml-0.5 size-3 lg:size-4" viewBox="0 0 24 24" fill="#211927">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="flex flex-1 items-center">
|
||||
<div class="h-1 w-full rounded-full bg-white/20">
|
||||
<div class="bg-primary-comfy-yellow h-full w-1/6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<span class="shrink-0 text-xs text-white/80 lg:text-sm">00:13</span>
|
||||
|
||||
<!-- Fullscreen button -->
|
||||
<button
|
||||
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-lg lg:size-10"
|
||||
:aria-label="locale === 'zh-CN' ? '全屏' : 'Fullscreen'"
|
||||
>
|
||||
<svg
|
||||
class="size-3.5 lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#211927"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mute button -->
|
||||
<button
|
||||
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-lg lg:size-10"
|
||||
:aria-label="locale === 'zh-CN' ? '静音' : 'Mute'"
|
||||
>
|
||||
<svg
|
||||
class="size-3.5 lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#211927"
|
||||
stroke="#211927"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M11 5L6 9H2v6h4l5 4V5z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line x1="23" y1="9" x2="17" y2="15" stroke-width="2.5" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" stroke-width="2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
268
apps/website/src/components/contact/FormSection.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
function tk(suffix: string): TranslationKey {
|
||||
return `contact.form.${suffix}` as TranslationKey
|
||||
}
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const company = ref('')
|
||||
const phone = ref('')
|
||||
const selectedPackage = ref('')
|
||||
const comfyUsage = ref('')
|
||||
const lookingFor = ref('')
|
||||
|
||||
const packageOptions = [
|
||||
'packageIndividual',
|
||||
'packageTeams',
|
||||
'packageEnterprise'
|
||||
] as const
|
||||
|
||||
const usageOptions = [
|
||||
'usingYesProduction',
|
||||
'usingYesTesting',
|
||||
'usingNotYet',
|
||||
'usingOtherTools'
|
||||
] as const
|
||||
|
||||
const inputClass =
|
||||
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
|
||||
|
||||
const sectionRef = ref<HTMLElement>()
|
||||
const badgeRef = ref<HTMLElement>()
|
||||
const headingRef = ref<HTMLElement>()
|
||||
const descRef = ref<HTMLElement>()
|
||||
const imageRef = ref<HTMLElement>()
|
||||
const formRef = ref<HTMLElement>()
|
||||
|
||||
useHeroAnimation({
|
||||
section: sectionRef,
|
||||
textEls: [badgeRef, headingRef, descRef],
|
||||
logo: imageRef,
|
||||
video: formRef,
|
||||
parallax: false
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
// TODO: implement form submission
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<span
|
||||
ref="badgeRef"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
|
||||
>
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 -ml-20 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src="/images/contact/c-projection.webp"
|
||||
alt=""
|
||||
class="w-full rounded-2xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: form -->
|
||||
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<!-- First Name + Last Name -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('firstName'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('firstNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lastName'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('lastNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company + Phone -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('company'), locale) }}*
|
||||
</label>
|
||||
<input
|
||||
v-model="company"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('companyPlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('phone'), locale) }}
|
||||
</label>
|
||||
<input v-model="phone" type="tel" :class="inputClass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package selection -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('packageQuestion'), locale) }}
|
||||
</p>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<label
|
||||
v-for="opt in packageOptions"
|
||||
:key="opt"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
|
||||
selectedPackage === opt
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="selectedPackage"
|
||||
type="radio"
|
||||
name="package"
|
||||
:value="opt"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
selectedPackage === opt
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-primary-warm-gray/40'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="selectedPackage === opt"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
{{ t(tk(opt), locale) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfy usage -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('usingComfy'), locale) }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<label
|
||||
v-for="opt in usageOptions"
|
||||
:key="opt"
|
||||
class="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
comfyUsage === opt
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="comfyUsage === opt"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="comfyUsage"
|
||||
type="radio"
|
||||
:value="opt"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(tk(opt), locale) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Looking for -->
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lookingFor'), locale) }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="lookingFor"
|
||||
:placeholder="t(tk('lookingForPlaceholder'), locale)"
|
||||
:class="cn(inputClass, 'min-h-24 resize-y')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="ppformula-text-center border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink cursor-pointer rounded-lg border px-8 py-3 text-sm font-bold tracking-wider transition-colors"
|
||||
>
|
||||
{{ t(tk('submit'), locale) }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
apps/website/src/components/customers/DetailHeroSection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
interface ButtonProps {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const { label, title, description, primaryButton, secondaryButton, image } =
|
||||
defineProps<{
|
||||
label?: string
|
||||
title: string
|
||||
description?: string
|
||||
primaryButton?: ButtonProps
|
||||
secondaryButton?: ButtonProps
|
||||
image?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
|
||||
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<span
|
||||
v-if="label"
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="description"
|
||||
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="primaryButton || secondaryButton"
|
||||
class="mt-8 flex flex-wrap justify-center gap-3"
|
||||
>
|
||||
<BrandButton
|
||||
v-if="primaryButton"
|
||||
:href="primaryButton.href"
|
||||
:label="primaryButton.label"
|
||||
variant="outline"
|
||||
/>
|
||||
<BrandButton
|
||||
v-if="secondaryButton"
|
||||
:href="secondaryButton.href"
|
||||
:label="secondaryButton.label"
|
||||
variant="solid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="image" class="mt-12 overflow-hidden px-6 lg:mt-16 lg:px-20">
|
||||
<img :src="image" :alt="title" class="w-full rounded-3xl object-cover" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
113
apps/website/src/components/customers/FeedbackSection.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const feedbacks = [
|
||||
{
|
||||
quote: 'customers.feedback.quote1' as const,
|
||||
name: 'customers.feedback.name1' as const,
|
||||
role: 'customers.feedback.role1' as const
|
||||
},
|
||||
{
|
||||
quote: 'customers.feedback.quote2' as const,
|
||||
name: 'customers.feedback.name2' as const,
|
||||
role: 'customers.feedback.role2' as const
|
||||
},
|
||||
{
|
||||
quote: 'customers.feedback.quote3' as const,
|
||||
name: 'customers.feedback.name3' as const,
|
||||
role: 'customers.feedback.role3' as const
|
||||
}
|
||||
]
|
||||
|
||||
const trackRef = ref<HTMLElement>()
|
||||
const progress = ref(0)
|
||||
|
||||
function updateProgress() {
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
const max = el.scrollWidth - el.clientWidth
|
||||
progress.value = max > 0 ? el.scrollLeft / max : 0
|
||||
}
|
||||
|
||||
function scroll(direction: -1 | 1) {
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
|
||||
onMounted(() => {
|
||||
trackRef.value?.addEventListener('scroll', updateProgress, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
trackRef.value?.removeEventListener('scroll', updateProgress)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-16 lg:px-16 lg:py-24">
|
||||
<!-- Scrollable track -->
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
|
||||
>
|
||||
<div
|
||||
v-for="(fb, i) in feedbacks"
|
||||
:key="i"
|
||||
class="bg-transparency-white-t4 flex w-full shrink-0 snap-start flex-col justify-between rounded-3xl p-8 lg:w-3/4 lg:p-12"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas text-2xl/relaxed font-light">
|
||||
"{{ t(fb.quote, locale) }}"
|
||||
</p>
|
||||
<div class="mt-12">
|
||||
<p class="text-primary-comfy-yellow text-base font-medium">
|
||||
{{ t(fb.name, locale) }},
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow text-base font-medium">
|
||||
{{ t(fb.role, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Prev -->
|
||||
<button
|
||||
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
|
||||
:aria-label="locale === 'zh-CN' ? '上一条' : 'Previous'"
|
||||
@click="scroll(-1)"
|
||||
>
|
||||
<img
|
||||
src="/icons/arrow-right.svg"
|
||||
alt=""
|
||||
class="size-3 rotate-180 opacity-60 invert"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Next -->
|
||||
<button
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
|
||||
:aria-label="locale === 'zh-CN' ? '下一条' : 'Next'"
|
||||
@click="scroll(1)"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
73
apps/website/src/components/customers/HeroSection.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const sectionRef = ref<HTMLElement>()
|
||||
const logoRef = ref<HTMLElement>()
|
||||
const labelRef = ref<HTMLElement>()
|
||||
const headingRef = ref<HTMLElement>()
|
||||
const bodyRef = ref<HTMLElement>()
|
||||
const videoRef = ref<HTMLElement>()
|
||||
|
||||
useHeroAnimation({
|
||||
section: sectionRef,
|
||||
textEls: [labelRef, headingRef, bodyRef],
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="sectionRef" class="pt-12 lg:pt-20">
|
||||
<div
|
||||
class="flex flex-col items-center text-center lg:flex-row lg:items-start lg:text-left"
|
||||
>
|
||||
<!-- 3D logo graphic -->
|
||||
<div
|
||||
ref="logoRef"
|
||||
class="order-2 mt-8 w-full lg:order-1 lg:mt-0 lg:w-5/12"
|
||||
>
|
||||
<img
|
||||
src="/images/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div
|
||||
class="order-1 flex flex-col items-center lg:order-2 lg:w-7/12 lg:items-start lg:pt-24 lg:pl-12"
|
||||
>
|
||||
<span
|
||||
ref="labelRef"
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('customers.hero.label', locale) }}
|
||||
</span>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
|
||||
>
|
||||
{{ t('customers.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
ref="bodyRef"
|
||||
class="text-primary-warm-gray mt-6 max-w-md text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('customers.hero.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video overlapping the hero graphic -->
|
||||
<div ref="videoRef" class="-mt-16 px-20 pb-40 lg:-mt-72">
|
||||
<VideoPlayer :locale />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
59
apps/website/src/components/customers/StorySection.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { customerStories } from '../../config/customerStories'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
:key="story.slug"
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
>
|
||||
<!-- Image -->
|
||||
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
|
||||
<div
|
||||
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${story.image})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col justify-between px-6 pt-4 pb-6">
|
||||
<div>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(story.category, locale) }}
|
||||
</span>
|
||||
<h3
|
||||
class="text-primary-comfy-canvas mt-2 text-lg/snug font-light lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 flex items-center gap-3 text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow flex size-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="ml-0.5 size-3" />
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas">
|
||||
{{ t('customers.story.viewArticle', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
13
apps/website/src/components/customers/VideoSection.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 py-16 lg:px-20 lg:py-40">
|
||||
<VideoPlayer :locale />
|
||||
</section>
|
||||
</template>
|
||||
56
apps/website/src/components/customers/WhatsNextSection.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
title,
|
||||
image,
|
||||
href,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
title: string
|
||||
image: string
|
||||
href: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
{{ t('customers.story.whatsNext' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
|
||||
>
|
||||
<a :href="href" class="shrink-0 lg:w-1/2">
|
||||
<img
|
||||
:src="image"
|
||||
:alt="title"
|
||||
class="w-full rounded-4xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<a :href="href" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('customers.story.viewArticle' as TranslationKey, locale) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
29
apps/website/src/components/gallery/ContactSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('gallery.contact.label', locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t('gallery.contact.heading', locale) }}
|
||||
</h2>
|
||||
<a
|
||||
href="mailto:contact@comfy.org"
|
||||
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink mt-8 inline-flex items-center justify-center rounded-2xl border-2 px-8 py-3 text-sm font-bold tracking-wider uppercase transition-colors"
|
||||
>
|
||||
{{ t('gallery.contact.cta', locale) }}
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
88
apps/website/src/components/gallery/GalleryCard.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
item,
|
||||
locale = 'en',
|
||||
hero = false,
|
||||
mobile = false
|
||||
} = defineProps<{
|
||||
item: GalleryItem
|
||||
locale?: Locale
|
||||
hero?: boolean
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group block cursor-pointer" @click="$emit('click')">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl"
|
||||
:class="hero ? 'aspect-21/9' : mobile ? 'aspect-4/3' : 'aspect-3/2'"
|
||||
>
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<!-- Desktop hover overlay -->
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="absolute inset-0 flex items-end bg-linear-to-t from-black/60 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t('gallery.card.by', locale) }}
|
||||
<span class="text-primary-comfy-yellow">{{
|
||||
item.userAlias
|
||||
}}</span>
|
||||
{{ t('gallery.card.and', locale) }}
|
||||
<span class="text-primary-comfy-yellow">{{
|
||||
item.teamAlias
|
||||
}}</span>
|
||||
{{ t('gallery.card.teamUsing', locale) }}
|
||||
<span class="text-primary-comfy-yellow">{{ item.tool }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
class="text-primary-comfy-ink"
|
||||
>
|
||||
<path
|
||||
d="M1 7h12m0 0L8 2m5 5L8 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t('gallery.card.by', locale) }}
|
||||
<span class="text-primary-comfy-yellow">{{ item.userAlias }}</span>
|
||||
{{ t('gallery.card.and', locale) }}
|
||||
<span class="text-primary-comfy-yellow">{{ item.teamAlias }}</span>
|
||||
{{ t('gallery.card.teamUsing', locale) }}
|
||||
<span class="text-primary-comfy-yellow">{{ item.tool }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
232
apps/website/src/components/gallery/GalleryDetailModal.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { GalleryItem } from './GallerySection.vue'
|
||||
|
||||
const {
|
||||
items,
|
||||
initialIndex = 0,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
items: GalleryItem[]
|
||||
initialIndex?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const activeIndex = ref(initialIndex)
|
||||
const transitioning = ref(false)
|
||||
const thumbnailRefs = ref<HTMLButtonElement[]>([])
|
||||
|
||||
const activeItem = computed(() => items[activeIndex.value])
|
||||
|
||||
function scrollToActiveThumbnail() {
|
||||
void nextTick(() => {
|
||||
thumbnailRefs.value[activeIndex.value]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(activeIndex, scrollToActiveThumbnail)
|
||||
|
||||
function selectThumbnail(index: number) {
|
||||
if (index === activeIndex.value || transitioning.value) return
|
||||
transitioning.value = true
|
||||
setTimeout(() => {
|
||||
activeIndex.value = index
|
||||
setTimeout(() => {
|
||||
transitioning.value = false
|
||||
}, 50)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
if (e.key === 'ArrowLeft' && activeIndex.value > 0)
|
||||
selectThumbnail(activeIndex.value - 1)
|
||||
if (e.key === 'ArrowRight' && activeIndex.value < items.length - 1)
|
||||
selectThumbnail(activeIndex.value + 1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => initialIndex,
|
||||
(val) => {
|
||||
activeIndex.value = val
|
||||
}
|
||||
)
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement>()
|
||||
|
||||
onMounted(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
dialogRef.value?.showModal()
|
||||
scrollToActiveThumbnail()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
:aria-label="activeItem.title"
|
||||
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-between border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
|
||||
@click="handleBackdropClick"
|
||||
@keydown="handleKeydown"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div class="relative hidden w-full items-start pt-12 lg:flex">
|
||||
<!-- Left: info card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
|
||||
>
|
||||
<div
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
class="gap-4 transition-opacity duration-200"
|
||||
>
|
||||
<h2 class="text-2xl font-bold">{{ activeItem.title }}</h2>
|
||||
<p class="mt-2 text-xs">
|
||||
{{ t('gallery.card.by', locale) }}
|
||||
<span class="font-bold">{{ activeItem.userAlias }}</span>
|
||||
{{ t('gallery.card.and', locale) }}
|
||||
<span class="font-bold">{{ activeItem.teamAlias }}</span>
|
||||
{{ t('gallery.card.teamUsing', locale) }}
|
||||
<span class="font-bold">{{ activeItem.tool }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
:href="activeItem.href"
|
||||
class="border-primary-comfy-ink hover:bg-primary-comfy-ink hover:text-primary-comfy-yellow mt-24 inline-flex items-center justify-center rounded-full border-2 px-6 py-3 text-sm font-bold tracking-wider uppercase transition-colors"
|
||||
>
|
||||
{{ t('gallery.detail.visitHub', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Node link connector (horizontal) -->
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
class="relative top-15 z-20 -mx-px h-6"
|
||||
/>
|
||||
|
||||
<!-- Right: large image -->
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex-1 overflow-hidden border-2 p-4"
|
||||
>
|
||||
<img
|
||||
:src="activeItem.image"
|
||||
:alt="activeItem.title"
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
class="size-full rounded-4xl object-cover transition-opacity duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div
|
||||
class="flex w-full flex-1 flex-col items-center justify-between pt-12 lg:hidden"
|
||||
>
|
||||
<!-- Image -->
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
|
||||
>
|
||||
<img
|
||||
:src="activeItem.image"
|
||||
:alt="activeItem.title"
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
class="w-full rounded-3xl object-cover transition-opacity duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node link connector (vertical) -->
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
alt=""
|
||||
class="relative z-20 -my-1 w-2 rotate-90"
|
||||
/>
|
||||
|
||||
<!-- Info card -->
|
||||
<div
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
|
||||
>
|
||||
<div
|
||||
:class="transitioning ? 'opacity-0' : 'opacity-100'"
|
||||
class="transition-opacity duration-200"
|
||||
>
|
||||
<h2 class="text-xl font-bold">{{ activeItem.title }}</h2>
|
||||
<p class="mt-2 text-xs">
|
||||
{{ t('gallery.card.by', locale) }}
|
||||
<span class="font-bold">{{ activeItem.userAlias }}</span>
|
||||
{{ t('gallery.card.and', locale) }}
|
||||
<span class="font-bold">{{ activeItem.teamAlias }}</span>
|
||||
{{ t('gallery.card.teamUsing', locale) }}
|
||||
<span class="font-bold">{{ activeItem.tool }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
:href="activeItem.href"
|
||||
class="border-primary-comfy-ink hover:bg-primary-comfy-ink hover:text-primary-comfy-yellow mt-6 inline-flex w-full items-center justify-center rounded-full border-2 px-6 py-3 text-sm font-bold tracking-wider uppercase transition-colors"
|
||||
>
|
||||
{{ t('gallery.detail.visitHub', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail strip -->
|
||||
<div class="mx-auto mt-6 max-w-full overflow-x-auto px-6">
|
||||
<div class="flex items-end gap-3">
|
||||
<button
|
||||
v-for="(item, i) in items"
|
||||
:ref="
|
||||
(el: any) => {
|
||||
if (el) thumbnailRefs[i] = el
|
||||
}
|
||||
"
|
||||
:key="i"
|
||||
class="shrink-0 cursor-pointer overflow-hidden rounded-xl border-0 bg-transparent p-0 transition-all duration-200"
|
||||
:class="
|
||||
i === activeIndex
|
||||
? 'ring-primary-comfy-yellow size-16 ring-2 lg:size-30'
|
||||
: 'size-12 opacity-70 hover:opacity-100 lg:size-22.5'
|
||||
"
|
||||
@click="selectThumbnail(i)"
|
||||
>
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
187
apps/website/src/components/gallery/GallerySection.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GalleryCard from './GalleryCard.vue'
|
||||
import GalleryDetailModal from './GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
function openDetail(index: number) {
|
||||
modalIndex.value = index
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
export interface GalleryItem {
|
||||
image: string
|
||||
title: string
|
||||
userAlias: string
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const items: GalleryItem[] = Array.from({ length: 12 }, () => ({
|
||||
image: '/images/gallery/gallery.webp',
|
||||
title: 'Image Title',
|
||||
userAlias: 'User Alias',
|
||||
teamAlias: 'Team Alias',
|
||||
tool: 'Tool',
|
||||
href: '#'
|
||||
}))
|
||||
|
||||
/**
|
||||
* Desktop layout pattern (repeating):
|
||||
* Row A: full-width (1 item)
|
||||
* Row B: 2-col equal (2 items)
|
||||
* Row C: 3-col equal (3 items)
|
||||
* Row D: large-left + 2 stacked small-right (3 items)
|
||||
* Row E: 2 stacked small-left + large-right (3 items)
|
||||
* = 1 + 2 + 3 + 3 + 3 = 12 items per cycle
|
||||
*/
|
||||
type RowLayout = 'full' | 'col-2' | 'col-3' | 'large-left' | 'large-right'
|
||||
const LAYOUT_PATTERN: RowLayout[] = [
|
||||
'full',
|
||||
'col-2',
|
||||
'col-3',
|
||||
'large-left',
|
||||
'large-right'
|
||||
]
|
||||
const ITEMS_PER_LAYOUT: Record<RowLayout, number> = {
|
||||
full: 1,
|
||||
'col-2': 2,
|
||||
'col-3': 3,
|
||||
'large-left': 3,
|
||||
'large-right': 3
|
||||
}
|
||||
|
||||
interface Row {
|
||||
layout: RowLayout
|
||||
items: GalleryItem[]
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
const rows: Row[] = []
|
||||
let idx = 0
|
||||
let patternIdx = 0
|
||||
|
||||
while (idx < items.length) {
|
||||
const layout = LAYOUT_PATTERN[patternIdx % LAYOUT_PATTERN.length]
|
||||
const count = ITEMS_PER_LAYOUT[layout]
|
||||
const slice = items.slice(idx, idx + count)
|
||||
if (slice.length === 0) break
|
||||
rows.push({ layout, items: slice, startIndex: idx })
|
||||
idx += slice.length
|
||||
patternIdx++
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 pb-20 lg:px-20">
|
||||
<!-- Desktop grid -->
|
||||
<div class="hidden flex-col gap-2 lg:flex">
|
||||
<template v-for="(row, rowIdx) in rows" :key="rowIdx">
|
||||
<!-- Symmetric rows: full / 2-col / 3-col -->
|
||||
<div
|
||||
v-if="
|
||||
row.layout === 'full' ||
|
||||
row.layout === 'col-2' ||
|
||||
row.layout === 'col-3'
|
||||
"
|
||||
class="grid grid-cols-6 gap-2"
|
||||
>
|
||||
<GalleryCard
|
||||
v-for="(item, i) in row.items"
|
||||
:key="i"
|
||||
:item="item"
|
||||
:locale="locale"
|
||||
:hero="row.layout === 'full'"
|
||||
:class="
|
||||
cn(
|
||||
row.layout === 'full' && 'col-span-6',
|
||||
row.layout === 'col-2' && 'col-span-3',
|
||||
row.layout === 'col-3' && 'col-span-2'
|
||||
)
|
||||
"
|
||||
@click="openDetail(row.startIndex + i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Large left + 2 stacked right -->
|
||||
<div
|
||||
v-else-if="row.layout === 'large-left'"
|
||||
class="grid grid-cols-3 gap-2"
|
||||
>
|
||||
<GalleryCard
|
||||
:item="row.items[0]"
|
||||
:locale="locale"
|
||||
class="col-span-2 row-span-2"
|
||||
@click="openDetail(row.startIndex)"
|
||||
/>
|
||||
<div class="col-span-1 flex flex-col gap-2">
|
||||
<GalleryCard
|
||||
:item="row.items[1]"
|
||||
:locale="locale"
|
||||
class="flex-1"
|
||||
@click="openDetail(row.startIndex + 1)"
|
||||
/>
|
||||
<GalleryCard
|
||||
:item="row.items[2]"
|
||||
:locale="locale"
|
||||
class="flex-1"
|
||||
@click="openDetail(row.startIndex + 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 stacked left + large right -->
|
||||
<div v-else class="grid grid-cols-3 gap-2">
|
||||
<div class="col-span-1 flex flex-col gap-2">
|
||||
<GalleryCard
|
||||
:item="row.items[0]"
|
||||
:locale="locale"
|
||||
class="flex-1"
|
||||
@click="openDetail(row.startIndex)"
|
||||
/>
|
||||
<GalleryCard
|
||||
:item="row.items[1]"
|
||||
:locale="locale"
|
||||
class="flex-1"
|
||||
@click="openDetail(row.startIndex + 1)"
|
||||
/>
|
||||
</div>
|
||||
<GalleryCard
|
||||
:item="row.items[2]"
|
||||
:locale="locale"
|
||||
class="col-span-2 row-span-2"
|
||||
@click="openDetail(row.startIndex + 2)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mobile list -->
|
||||
<div class="flex flex-col gap-6 lg:hidden">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:item="item"
|
||||
:locale="locale"
|
||||
mobile
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryDetailModal
|
||||
v-if="modalOpen"
|
||||
:items="items"
|
||||
:initial-index="modalIndex"
|
||||
:locale="locale"
|
||||
@close="modalOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
25
apps/website/src/components/gallery/HeroSection.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('gallery.label', locale) }}
|
||||
</span>
|
||||
<h1
|
||||
class="text-primary-warm-white mt-4 max-w-3xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('gallery.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span>
|
||||
</h1>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-lg text-sm lg:text-base">
|
||||
{{ t('gallery.heroSubtitle', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||