diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..ac5df3c1f3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")", + "Bash(pnpx vitest run \"draftCacheV2.property\")", + "Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")" + ] + } +} diff --git a/.claude/skills/ticket-intake/SKILL.md b/.claude/skills/ticket-intake/SKILL.md new file mode 100644 index 0000000000..f01c7f763d --- /dev/null +++ b/.claude/skills/ticket-intake/SKILL.md @@ -0,0 +1,361 @@ +--- +name: ticket-intake +description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.' +--- + +# Ticket Intake + +Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API. + +> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below. + +## Supported Sources + +| Source | URL Pattern | Provider File | +| ------ | --------------------------------------------------- | --------------------- | +| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` | +| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` | + +## Quick Start + +When given a ticket URL: + +1. **Detect source type** from URL pattern +2. **Load provider-specific logic** from `providers/` directory +3. Fetch ticket content via appropriate API +4. Extract and normalize properties to common schema +5. **Register ticket in pipeline API** ← MANDATORY +6. **Update source** (Notion status / GitHub comment) ← MANDATORY +7. **Run verification script** to confirm API registration +8. Output summary and handoff to `research-orchestrator` + +## Configuration + +Uses the **production API** by default. No configuration needed for read operations. + +**Defaults (no setup required):** + +- API URL: `https://api-gateway-856475788601.us-central1.run.app` +- Read-only endpoints at `/public/*` require no authentication + +**For write operations** (transitions, creating tickets), set: + +```bash +export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin +``` + +**Optional (for local working artifacts):** + +```bash +PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}" +``` + +## Mandatory API Calls (Execute ALL Three) + +**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.** + +**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation. + +### Call 1: Create Ticket + +```bash +API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}" +API_KEY="${PIPELINE_API_KEY}" + +curl -s -X POST "${API_URL}/v1/tickets" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \ + -d '{ + "notion_page_id": "NOTION_PAGE_UUID_HERE", + "title": "TICKET_TITLE_HERE", + "source": "notion", + "metadata": { + "description": "DESCRIPTION_HERE", + "priority": "High", + "labels": [], + "acceptanceCriteria": [] + } + }' +``` + +Save the returned `id` — you need it for the next two calls. + +### Call 2: Transition to RESEARCH + +```bash +TICKET_ID="id-from-step-1" + +curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \ + -d '{ + "to_state": "RESEARCH", + "reason": "Intake complete, starting research" + }' +``` + +### Call 3: Queue Source Update + +```bash +curl -s -X POST "${API_URL}/v1/sync/queue" \ + -H "Authorization: Bearer ${API_KEY}" \ + -H "Content-Type: application/json" \ + -H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \ + -d '{ + "ticket_id": "TICKET_ID_HERE", + "action": "update_status", + "payload": { "status": "In Progress" }, + "priority": "normal" + }' +``` + +> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`. + +### TypeScript Equivalent (if using pipeline client) + +```typescript +import { PipelineClient } from '@pipeline/client' + +const client = new PipelineClient({ + apiUrl: + process.env.PIPELINE_API_URL || + 'https://api-gateway-856475788601.us-central1.run.app', + agentId: process.env.AGENT_ID! +}) + +const ticket = await client.createTicket({ + notion_page_id: pageId, + title: ticketTitle, + source: 'notion', + metadata: { description, priority, labels, acceptanceCriteria } +}) + +await client.transitionState( + ticket.id, + 'RESEARCH', + 'Intake complete, starting research' +) + +await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' }) +``` + +## Workflow + +### Step 1: Detect Source Type + +Parse the URL to determine source: + +```javascript +if (url.includes('notion.so')) { + source = 'notion' + // Load providers/notion.md +} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) { + source = 'github' + // Load providers/github.md +} else { + // Error: Unsupported URL format +} +``` + +### Step 2: Load Provider and Fetch Data + +Read the appropriate provider file for source-specific instructions: + +- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links +- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments + +Follow the provider's instructions for: + +- Fetching content +- Extracting properties +- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner) + +### Step 3: Normalize to Common Schema + +All providers must extract normalized ticket data following `schema.md`: + +```json +{ + "id": "abc12345", + "url": "https://...", + "source": "notion | github", + "title": "Ticket title", + "description": "Full description", + "status": "Not Started", + "assignee": "username", + "priority": "High", + "area": "UI", + "labels": ["bug", "frontend"], + "acceptanceCriteria": ["Criterion 1", "Criterion 2"], + "fetchedAt": "2024-01-15T10:30:00Z" +} +``` + +### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP) + +**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.** + +This is not optional. This is not documentation. You MUST make these HTTP requests right now. + +1. `createTicket()` → save the returned ticket ID +2. `transitionState(id, 'RESEARCH')` → confirm state changed +3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued + +**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue. + +### Step 5: Run Verification Script + +After making the API calls, run the verification script to confirm everything worked: + +```bash +bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID +``` + +**If the script is not available locally**, verify manually via the public API: + +```bash +curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}' +``` + +Expected output: + +```json +{ + "id": "...", + "state": "RESEARCH", + "title": "...", + "notion_page_id": "..." +} +``` + +**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.** + +### Step 6: Output Summary and Handoff + +Print a clear summary: + +```markdown +## Ticket Intake Complete + +**Source:** Notion | GitHub +**Title:** [Ticket title] +**ID:** abc12345 +**Status:** In Progress (queued) +**Priority:** High +**Area:** UI + +### Description + +[Brief description or first 200 chars] + +### Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 + +### Links + +- **Ticket:** [Original URL] +- **Slack:** [Slack thread content fetched via slackdump] (Notion only) + +### Pipeline + +- **API Ticket ID:** abc12345 +- **State:** RESEARCH +- **Verified:** ✅ (via verify-intake.sh or public API) +``` + +**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any): + +> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}" + +**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately. + +## Error Handling + +### Unsupported URL + +``` +❌ Unsupported ticket URL format. + +Supported formats: +- Notion: https://notion.so/... or https://www.notion.so/... +- GitHub: https://github.com/{owner}/{repo}/issues/{number} + +Received: [provided URL] +``` + +### Provider-Specific Errors + +See individual provider files for source-specific error handling: + +- `providers/notion.md` - Authentication, page not found +- `providers/github.md` - Auth, rate limits, issue not found + +### Missing Properties + +Continue with available data and note what's missing: + +``` +⚠️ Some properties unavailable: +- Priority: not found (using default: Medium) +- Area: not found + +Proceeding with available data... +``` + +### API Call Failures + +``` +❌ Pipeline API call failed: {method} {endpoint} + Status: {status} + Error: {message} + +Retrying once... + +❌ Retry also failed. INTAKE IS INCOMPLETE. + The ticket was NOT registered in the pipeline. + Downstream skills will not work until this is fixed. +``` + +## Notes + +- This skill focuses ONLY on intake — it does not do research +- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed +- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them +- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"` +- Pipeline state is tracked via the API, not local files +- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/` +- The `source` field in the ticket determines which research strategies to use + +## API Client Reference + +### Available Methods + +| Method | Description | +| ----------------------------------------------------------- | ------------------------------------------------------------------- | +| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API | +| `getTicket(id)` | Retrieve a ticket by ID | +| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID | +| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters | +| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) | +| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created | +| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) | +| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection | + +### Error Handling + +```typescript +import { PipelineClient, PipelineAPIError } from '@pipeline/client'; + +try { + await client.createTicket({ ... }); +} catch (error) { + if (error instanceof PipelineAPIError) { + console.error(`API Error ${error.status}: ${error.message}`); + } + throw error; +} +``` diff --git a/.claude/skills/ticket-intake/providers/github.md b/.claude/skills/ticket-intake/providers/github.md new file mode 100644 index 0000000000..7ebaf1082f --- /dev/null +++ b/.claude/skills/ticket-intake/providers/github.md @@ -0,0 +1,194 @@ +# GitHub Provider - Ticket Intake + +Provider-specific logic for ingesting tickets from GitHub Issues. + +## URL Pattern + +``` +https://github.com/{owner}/{repo}/issues/{number} +https://www.github.com/{owner}/{repo}/issues/{number} +``` + +Extract: `owner`, `repo`, `issue_number` from URL. + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`) +- Access to the repository + +## Fetch Issue Content + +Use `gh` CLI to fetch issue details: + +```bash +# Get issue details in JSON +gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs + +# Get comments separately if needed +gh issue view {number} --repo {owner}/{repo} --comments +``` + +## Extract Ticket Data + +Map GitHub issue fields to normalized ticket data (stored via API): + +| GitHub Field | ticket.json Field | Notes | +| ------------ | ----------------- | -------------------------- | +| title | title | Direct mapping | +| body | description | Issue body/description | +| state | status | Map: open → "Not Started" | +| labels | labels | Array of label names | +| assignees | assignee | First assignee login | +| author | author | Issue author login | +| milestone | milestone | Milestone title if present | +| comments | comments | Array of comment objects | +| linkedPRs | linkedPRs | PRs linked to this issue | + +### Priority Mapping + +Infer priority from labels: + +- `priority:critical`, `P0` → "Critical" +- `priority:high`, `P1` → "High" +- `priority:medium`, `P2` → "Medium" +- `priority:low`, `P3` → "Low" +- No priority label → "Medium" (default) + +### Area Mapping + +Infer area from labels: + +- `area:ui`, `frontend`, `component:*` → "UI" +- `area:api`, `backend` → "API" +- `area:docs`, `documentation` → "Docs" +- `bug`, `fix` → "Bug" +- `enhancement`, `feature` → "Feature" + +## Update Source + +**For GitHub issues, update is optional but recommended.** + +Add a comment to indicate work has started: + +```bash +gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue." +``` + +Optionally assign to self: + +```bash +gh issue edit {number} --repo {owner}/{repo} --add-assignee @me +``` + +Log any updates via the Pipeline API: + +```typescript +await client.updateTicket(ticketId, { + metadata: { + ...ticket.metadata, + githubWrites: [ + ...(ticket.metadata?.githubWrites || []), + { + action: 'comment', + issueNumber: 123, + at: new Date().toISOString(), + skill: 'ticket-intake', + success: true + } + ] + } +}) +``` + +## GitHub-Specific Ticket Fields + +Store via API using `client.createTicket()`: + +```json +{ + "source": "github", + "githubOwner": "Comfy-Org", + "githubRepo": "ComfyUI_frontend", + "githubIssueNumber": 123, + "githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123", + "labels": ["bug", "area:ui", "priority:high"], + "linkedPRs": [456, 789], + "dosuComment": "..." // Extracted Dosu bot analysis if present +} +``` + +## Dosu Bot Detection + +Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu: + +```bash +gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu" +``` + +Look for comments from: + +- `dosu[bot]` +- `dosu-bot` + +Extract Dosu analysis which typically includes: + +- Root cause analysis +- Suggested files to modify +- Related issues/PRs +- Potential solutions + +Store in ticket data via API: + +```json +{ + "dosuComment": { + "found": true, + "analysis": "...", + "suggestedFiles": ["src/file1.ts", "src/file2.ts"], + "relatedIssues": [100, 101] + } +} +``` + +## Extract Linked Issues/PRs + +Parse issue body and comments for references: + +- `#123` → Issue or PR reference +- `fixes #123`, `closes #123` → Linked issue +- `https://github.com/.../issues/123` → Full URL reference + +Store in ticket data via API for research phase: + +```json +{ + "referencedIssues": [100, 101, 102], + "referencedPRs": [200, 201] +} +``` + +## Error Handling + +### Authentication Error + +``` +⚠️ GitHub CLI not authenticated. +Run: gh auth login +``` + +### Issue Not Found + +``` +❌ GitHub issue not found or inaccessible. +- Check the URL is correct +- Ensure you have access to this repository +- Run: gh auth status +``` + +### Rate Limiting + +``` +⚠️ GitHub API rate limited. +Wait a few minutes and try again. +Check status: gh api rate_limit +``` diff --git a/.claude/skills/ticket-intake/providers/notion.md b/.claude/skills/ticket-intake/providers/notion.md new file mode 100644 index 0000000000..42153f9dfe --- /dev/null +++ b/.claude/skills/ticket-intake/providers/notion.md @@ -0,0 +1,202 @@ +# Notion Provider - Ticket Intake + +Provider-specific logic for ingesting tickets from Notion. + +## URL Pattern + +``` +https://www.notion.so/workspace/Page-Title-abc123def456... +https://notion.so/Page-Title-abc123def456... +https://www.notion.so/abc123def456... +``` + +Page ID is the 32-character hex string (with or without hyphens). + +## Prerequisites + +- Notion MCP connected and authenticated +- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp` +- Authenticate via `/mcp` command if prompted + +## Fetch Ticket Content + +Use `Notion:notion-fetch` with the page URL or ID: + +``` +Fetch the full page content including all properties +``` + +## Extract Ticket Data + +Extract these properties (names may vary): + +| Property | Expected Name | Type | +| ------------- | ------------------------- | ------------ | +| Title | Name / Title | Title | +| Status | Status | Select | +| Assignee | Assignee / Assigned To | Person | +| Description | - | Page content | +| Slack Link | Slack Link / Slack Thread | URL | +| GitHub PR | GitHub PR / PR Link | URL | +| Priority | Priority | Select | +| Area | Area / Category | Select | +| Related Tasks | Related Tasks | Relation | + +**If properties are missing**: Note what's unavailable and continue with available data. + +## Update Source (REQUIRED) + +**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.** + +**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):** + +- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written +- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done +- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status + +Use `Notion:notion-update-page` to update the ticket: + +1. **Status**: Set to "In Progress" (only valid from "Not Started") +2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`) + +```json +{ + "page_id": "{page_id_from_ticket}", + "command": "update_properties", + "properties": { + "Status": "In Progress", + "Assignee": "175d872b-594c-81d4-ba5a-0002911c5966" + } +} +``` + +**After the update succeeds**, log the write via the Pipeline API: + +```typescript +await client.updateTicket(ticketId, { + metadata: { + ...ticket.metadata, + notionWrites: [ + ...(ticket.metadata?.notionWrites || []), + { + field: 'Status', + value: 'In Progress', + previousValue: 'Not Started', + at: new Date().toISOString(), + skill: 'ticket-intake', + success: true + } + ] + } +}) +``` + +If update fails, log with `success: false` and continue. + +## Notion-Specific Ticket Fields + +Store via API using `client.createTicket()`: + +```json +{ + "source": "notion", + "notionPageId": "abc123def456...", + "slackLink": "https://slack.com/...", + "relatedTasks": ["page-id-1", "page-id-2"] +} +``` + +## Slack Thread Handling + +If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically. + +### Slack URL Conversion + +Notion stores Slack links in `slackMessage://` format: + +``` +slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS +``` + +Convert to browser-clickable format: + +``` +https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT +``` + +**Example:** + +- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829` +- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829` + +(Remove the dot from the last timestamp and prefix with `p`) + +### Fetching Thread Content + +Load the `slackdump` skill and use the **export-thread** workflow: + +```bash +# Export thread by URL +slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS" + +# Or by colon notation (channel_id:thread_ts) +slackdump dump CHANNEL_ID:THREAD_TS +``` + +Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata. + +> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`. + +## Database Reference: Comfy Tasks + +The "Comfy Tasks" database has these properties (verify via `notion-search`): + +- **Status values**: Not Started, In Progress, In Review, Done +- **Team assignment**: "Frontend Team" for unassigned tickets +- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully + +### Pipeline Owner Details + +When assigning tickets, use these identifiers: + +| Platform | Identifier | +| --------------- | -------------------------------------- | +| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` | +| Notion Name | Christian Byrne | +| Notion Email | cbyrne@comfy.org | +| Slack User ID | U087MJCDHHC | +| GitHub Username | christian-byrne | + +**To update Assignee**, use the Notion User ID (not name): + +``` +properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"} +``` + +### Finding Active Tickets + +To list your active tickets: + +``` +Use Notion:notion-search for "Comfy Tasks" +Filter by Assignee = current user OR Team = "Frontend Team" +``` + +## Error Handling + +### Authentication Error + +``` +⚠️ Notion authentication required. +Run: claude mcp add --transport http notion https://mcp.notion.com/mcp +Then authenticate via /mcp command. +``` + +### Page Not Found + +``` +❌ Notion page not found or inaccessible. +- Check the URL is correct +- Ensure you have access to this page +- Try re-authenticating via /mcp +``` diff --git a/.claude/skills/ticket-intake/schema.md b/.claude/skills/ticket-intake/schema.md new file mode 100644 index 0000000000..b82a798df1 --- /dev/null +++ b/.claude/skills/ticket-intake/schema.md @@ -0,0 +1,81 @@ +# Ticket Schema + +Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files. + +## Ticket Data Schema + +```json +{ + // Required fields (all sources) + "id": "string", // Unique identifier (short form) + "url": "string", // Original URL + "source": "notion | github", // Source type + "title": "string", // Ticket title + "description": "string", // Full description/body + "fetchedAt": "ISO8601", // When ticket was fetched + + // Common optional fields + "status": "string", // Current status + "assignee": "string", // Assigned user + "priority": "string", // Priority level + "area": "string", // Category/area + "labels": ["string"], // Tags/labels + "acceptanceCriteria": ["string"] // List of AC items + + // Source-specific fields (see providers) + // Notion: notionPageId, slackLink, relatedTasks, notionWrites + // GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues +} +``` + +## Ticket State Schema (via API) + +State is managed via the Pipeline API using `client.transitionState()`: + +```json +{ + "ticketId": "string", + "state": "intake | research | planning | implementation | pr_created | done | failed", + "stateChangedAt": "ISO8601", + + // Timestamps tracked by API + "createdAt": "ISO8601", + "updatedAt": "ISO8601" +} +``` + +## Priority Normalization + +All sources should normalize to these values: + +| Normalized | Description | +| ---------- | ------------------------- | +| Critical | Production down, security | +| High | Blocking work, urgent | +| Medium | Normal priority (default) | +| Low | Nice to have, backlog | + +## Status Normalization + +Pipeline tracks these statuses internally: + +| Status | Description | +| -------------- | ---------------------------- | +| research | Gathering context | +| planning | Creating implementation plan | +| implementation | Writing code | +| review | Code review in progress | +| qa | Quality assurance | +| done | PR merged or completed | + +## ID Generation + +IDs are generated by the API when creating tickets. For reference: + +- **Notion**: First 8 characters of page ID +- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized) + +Examples: + +- Notion: `abc12345` +- GitHub: `gh-comfy-org-frontend-123` diff --git a/.claude/skills/writing-storybook-stories/SKILL.md b/.claude/skills/writing-storybook-stories/SKILL.md new file mode 100644 index 0000000000..a49f3a1103 --- /dev/null +++ b/.claude/skills/writing-storybook-stories/SKILL.md @@ -0,0 +1,143 @@ +--- +name: writing-storybook-stories +description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.' +--- + +# Write Storybook Stories for ComfyUI_frontend + +## Workflow + +1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch. +2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables. +3. Read nearby stories before writing anything. + - Search stories: `rg --files src apps | rg '\.stories\.ts$'` + - Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'` +4. If a Figma link is provided, list the states you need to cover before writing stories. +5. Co-locate the story file with the component: `ComponentName.stories.ts`. +6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story. +7. Run Storybook and validation checks before handing off. + +## Match Local Conventions + +- Copy the closest neighboring story instead of forcing one universal template. +- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area. +- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it. +- Use `ComponentPropsAndSlots` when it helps with prop and slot typing. +- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly. +- Use `args.default` or other slot-shaped args when the component content is provided through slots. +- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing. +- Add decorators for realistic width or background context when the component needs it. + +## Title Patterns + +Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern. + +| Component area | Typical title pattern | +| ------------------------------------------------------- | ------------------------------------ | +| `src/components/ui/button/Button.vue` | `Components/Button/Button` | +| `src/components/ui/input/Input.vue` | `Components/Input` | +| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` | +| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` | +| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/` | +| `src/platform/assets/components/*` | `Platform/Assets/` | +| `apps/desktop-ui/src/components/*` | `Desktop/Components/` | +| `apps/desktop-ui/src/views/*` | `Desktop/Views/` | + +If multiple patterns seem plausible, follow the closest sibling story in the same folder tree. + +## Common Story Shapes + +### Stateful input or `v-model` + +```typescript +export const Default: Story = { + render: (args) => ({ + components: { MyComponent }, + setup() { + const { disabled, size } = toRefs(args) + const value = ref('Hello world') + return { value, disabled, size } + }, + template: + '' + }) +} +``` + +### Slot-driven content + +```typescript +const meta: Meta> = { + argTypes: { + default: { control: 'text' } + }, + args: { + default: 'Button' + } +} + +export const SingleButton: Story = { + render: (args) => ({ + components: { Button }, + setup() { + return { args } + }, + template: '' + }) +} +``` + +### Variants or edge cases grid + +```typescript +export const AllVariants: Story = { + render: () => ({ + components: { MyComponent }, + template: ` +
+ + + + +
+ ` + }) +} +``` + +## Figma Mapping + +- Extract the named states from the design first. +- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`. +- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful. +- Use pseudo-state parameters only if the addon is already configured in this repo. +- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs. + +## Component-Specific Notes + +- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`. +- Input and search components often need a width-constrained wrapper so they render at realistic sizes. +- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers. +- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files. +- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions. + +## Checklist + +- [ ] Read the component source and any supporting types or composables +- [ ] Match the nearest local title pattern and story style +- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions +- [ ] Add focused stories for meaningful states +- [ ] Add `tags: ['autodocs']` +- [ ] Keep the story co-located with the component +- [ ] Run `pnpm storybook` +- [ ] Run `pnpm typecheck` +- [ ] Run `pnpm lint` + +## Avoid + +- Do not guess props, emits, slots, or exposed methods. +- Do not force one generic title convention across the repo. +- Do not mutate Storybook args directly for `v-model` components. +- Do not introduce `dark:` Tailwind variants in story wrappers. +- Do not create barrel files. +- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern. diff --git a/.claude/skills/writing-storybook-stories/agents/openai.yaml b/.claude/skills/writing-storybook-stories/agents/openai.yaml new file mode 100644 index 0000000000..a70d5516ce --- /dev/null +++ b/.claude/skills/writing-storybook-stories/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'ComfyUI Storybook Stories' + short_description: 'Write Vue Storybook stories for ComfyUI' + default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.' diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..aa5e796e1c --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.claude/worktrees diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png index 1da9697134..2c3b8b79bc 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png index 5cddb2fbbb..c061e7722d 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png index 8ac7f53585..0287b037e0 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png index 7c98f95a1e..eb3061a121 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png index 5f55b2b880..6b5297935a 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png index 5d38e7ec73..389d77a97f 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png index d79a76015b..a6888514ff 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png index af6664793c..ff04d866db 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png index 81a3627fbc..c22ac35de9 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png index 07a82de3a3..e3b521cba0 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png index d3d05eb3cf..e880757722 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png index 362250e32e..4ad8ab5fe1 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png index 02420975a1..cce2ed2ed3 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png index 80281999ff..05adc8df4a 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png index 3b8b9293c7..6b2cf3eb5f 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png index 778da6f5a5..d16c7cdd2c 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png index e6c5956084..2095eb5a37 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png index 8218a53f73..0cb1498b76 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png index 83e2b8f8d6..849bc294d2 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png index 99edcbdad5..38d645d69a 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png differ diff --git a/knip.config.ts b/knip.config.ts index 23379bb0e6..3d9c431820 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -11,7 +11,7 @@ const config: KnipConfig = { 'src/types/index.ts', 'src/storybook/mocks/**/*.ts' ], - project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'] + project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**'] }, 'apps/desktop-ui': { entry: ['src/main.ts', 'src/i18n.ts'], diff --git a/original-8188-model-filter.png b/original-8188-model-filter.png new file mode 100644 index 0000000000..3bc8c1d9fa Binary files /dev/null and b/original-8188-model-filter.png differ diff --git a/src/components/ui/chart/ChartBar.stories.ts b/src/components/ui/chart/ChartBar.stories.ts new file mode 100644 index 0000000000..5a36d8425e --- /dev/null +++ b/src/components/ui/chart/ChartBar.stories.ts @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import ChartBar from './ChartBar.vue' + +const meta: Meta = { + title: 'Components/Chart/ChartBar', + component: ChartBar, + tags: ['autodocs'], + parameters: { layout: 'centered' }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + ariaLabel: 'Bar chart example', + data: { + labels: ['A', 'B', 'C', 'D'], + datasets: [ + { + label: 'BarName1', + data: [10, 50, 35, 75], + backgroundColor: '#ff8000' + } + ] + } + } +} + +export const MultipleDatasets: Story = { + args: { + ariaLabel: 'Bar chart with multiple datasets', + data: { + labels: ['A', 'B', 'C', 'D'], + datasets: [ + { + label: 'Series 1', + data: [30, 60, 45, 80], + backgroundColor: '#ff8000' + }, + { + label: 'Series 2', + data: [50, 40, 70, 20], + backgroundColor: '#4ade80' + } + ] + } + } +} diff --git a/src/components/ui/chart/ChartBar.vue b/src/components/ui/chart/ChartBar.vue new file mode 100644 index 0000000000..b9fd6ca106 --- /dev/null +++ b/src/components/ui/chart/ChartBar.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/ui/chart/ChartLine.stories.ts b/src/components/ui/chart/ChartLine.stories.ts new file mode 100644 index 0000000000..7d717adfc0 --- /dev/null +++ b/src/components/ui/chart/ChartLine.stories.ts @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import ChartLine from './ChartLine.vue' + +const meta: Meta = { + title: 'Components/Chart/ChartLine', + component: ChartLine, + tags: ['autodocs'], + parameters: { layout: 'centered' }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + ariaLabel: 'Line chart example', + data: { + labels: ['A', 'B', 'C', 'D'], + datasets: [ + { + label: 'LineName1', + data: [10, 45, 25, 80], + borderColor: '#4ade80', + borderDash: [5, 5], + fill: true, + backgroundColor: '#4ade8033', + tension: 0.4 + } + ] + } + } +} + +export const MultipleLines: Story = { + args: { + ariaLabel: 'Line chart with multiple lines', + data: { + labels: ['A', 'B', 'C', 'D'], + datasets: [ + { + label: 'LineName1', + data: [10, 45, 25, 80], + borderColor: '#4ade80', + borderDash: [5, 5], + fill: true, + backgroundColor: '#4ade8033', + tension: 0.4 + }, + { + label: 'LineName2', + data: [80, 60, 40, 10], + borderColor: '#ff8000', + fill: true, + backgroundColor: '#ff800033', + tension: 0.4 + }, + { + label: 'LineName3', + data: [60, 70, 35, 40], + borderColor: '#ef4444', + fill: true, + backgroundColor: '#ef444433', + tension: 0.4 + } + ] + } + } +} diff --git a/src/components/ui/chart/ChartLine.vue b/src/components/ui/chart/ChartLine.vue new file mode 100644 index 0000000000..ebac219665 --- /dev/null +++ b/src/components/ui/chart/ChartLine.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/ui/chart/useChart.ts b/src/components/ui/chart/useChart.ts new file mode 100644 index 0000000000..4f2470d0ca --- /dev/null +++ b/src/components/ui/chart/useChart.ts @@ -0,0 +1,196 @@ +import type { ChartData, ChartOptions, ChartType } from 'chart.js' +import { + BarController, + BarElement, + CategoryScale, + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip +} from 'chart.js' +import { onBeforeUnmount, onMounted, ref, watch } from 'vue' + +import type { Ref } from 'vue' + +Chart.register( + BarController, + BarElement, + CategoryScale, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip +) + +function getCssVar(name: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() +} + +function getDefaultOptions(type: ChartType): ChartOptions { + const foreground = getCssVar('--color-base-foreground') || '#ffffff' + const muted = getCssVar('--color-muted-foreground') || '#8a8a8a' + + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + align: 'start', + labels: { + color: foreground, + usePointStyle: true, + pointStyle: 'circle', + boxWidth: 8, + boxHeight: 8, + padding: 16, + font: { family: 'Inter', size: 11 }, + generateLabels(chart) { + const datasets = chart.data.datasets + return datasets.map((dataset, i) => { + const color = + (dataset as { borderColor?: string }).borderColor ?? + (dataset as { backgroundColor?: string }).backgroundColor ?? + '#888' + return { + text: dataset.label ?? '', + fillStyle: color as string, + strokeStyle: color as string, + lineWidth: 0, + pointStyle: 'circle' as const, + hidden: !chart.isDatasetVisible(i), + datasetIndex: i + } + }) + } + } + }, + tooltip: { + enabled: true + } + }, + elements: { + point: { + radius: 0, + hoverRadius: 4 + } + }, + scales: { + x: { + ticks: { + color: muted, + font: { family: 'Inter', size: 11 }, + padding: 8 + }, + grid: { + display: true, + color: muted + '33', + drawTicks: false + }, + border: { display: true, color: muted } + }, + y: { + ticks: { + color: muted, + font: { family: 'Inter', size: 11 }, + padding: 4 + }, + grid: { + display: false, + drawTicks: false + }, + border: { display: true, color: muted } + } + }, + ...(type === 'bar' && { + datasets: { + bar: { + borderRadius: { topLeft: 4, topRight: 4 }, + borderSkipped: false, + barPercentage: 0.6, + categoryPercentage: 0.8 + } + } + }) + } +} + +export function useChart( + canvasRef: Ref, + type: Ref, + data: Ref, + options?: Ref +) { + const chartInstance = ref(null) + + function createChart() { + if (!canvasRef.value) return + + chartInstance.value?.destroy() + + const defaults = getDefaultOptions(type.value) + const merged = options?.value + ? deepMerge(defaults, options.value) + : defaults + + chartInstance.value = new Chart(canvasRef.value, { + type: type.value, + data: data.value, + options: merged + }) + } + + onMounted(createChart) + + watch([type, data, options ?? ref(undefined)], () => { + if (chartInstance.value) { + chartInstance.value.data = data.value + chartInstance.value.options = options?.value + ? deepMerge(getDefaultOptions(type.value), options.value) + : getDefaultOptions(type.value) + chartInstance.value.update() + } + }) + + onBeforeUnmount(() => { + chartInstance.value?.destroy() + chartInstance.value = null + }) + + return { chartInstance } +} + +function deepMerge>( + target: T, + source: Record +): T { + const result = { ...target } as Record + for (const key of Object.keys(source)) { + const srcVal = source[key] + const tgtVal = result[key] + if ( + srcVal && + typeof srcVal === 'object' && + !Array.isArray(srcVal) && + tgtVal && + typeof tgtVal === 'object' && + !Array.isArray(tgtVal) + ) { + result[key] = deepMerge( + tgtVal as Record, + srcVal as Record + ) + } else { + result[key] = srcVal + } + } + return result as T +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.stories.ts b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.stories.ts new file mode 100644 index 0000000000..50b7f19906 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.stories.ts @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import { CONTROL_OPTIONS } from '@/types/simplifiedWidget' + +import ValueControlButton from './ValueControlButton.vue' + +const meta: Meta = { + title: 'Components/InputHelpers/ValueControlButton', + component: ValueControlButton, + tags: ['autodocs'], + parameters: { layout: 'centered' }, + argTypes: { + mode: { control: 'select', options: [...CONTROL_OPTIONS] }, + variant: { control: 'select', options: ['badge', 'button'] } + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Randomize: Story = { + args: { mode: 'randomize', variant: 'badge' } +} + +export const Fixed: Story = { + args: { mode: 'fixed', variant: 'badge' } +} + +export const Increment: Story = { + args: { mode: 'increment', variant: 'badge' } +} + +export const Decrement: Story = { + args: { mode: 'decrement', variant: 'badge' } +} + +export const AllModes: Story = { + render: () => ({ + components: { ValueControlButton }, + template: ` +
+
+

Badge

+
+ + + + +
+
+
+

Button

+
+ + + + +
+
+
+ ` + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue new file mode 100644 index 0000000000..2eb9b68928 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/ValueControlButton.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue index 89eed58847..8f0dd32476 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue @@ -1,14 +1,15 @@