From 9eee3cf68fd4ff680378523e0eb56748fe01dc6f Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 4 Dec 2025 20:52:40 +0900 Subject: [PATCH 01/22] feat(gen-docs): implement ComponentDetector for identifying React component patterns --- .../commands/gen-docs/componentDetector.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 packages/cli/src/commands/gen-docs/componentDetector.ts diff --git a/packages/cli/src/commands/gen-docs/componentDetector.ts b/packages/cli/src/commands/gen-docs/componentDetector.ts new file mode 100644 index 00000000..f2b34b66 --- /dev/null +++ b/packages/cli/src/commands/gen-docs/componentDetector.ts @@ -0,0 +1,232 @@ +import { + type CallExpression, + Node, + SyntaxKind, + type VariableDeclaration, +} from "ts-morph" + +/** + * React 컴포넌트 감지 클래스 + * 다양한 React 컴포넌트 패턴을 식별하는 유틸리티 + */ +export class ComponentDetector { + /** + * 기본 함수/화살표 함수 컴포넌트 체크 + * + * 판단 기준: + * - JSX 요소를 반환하는가? (JsxElement, JsxSelfClosingElement, JsxFragment) + * - 함수명이 대문자로 시작하는가? (React 컴포넌트 네이밍 규칙) + * + * 감지 가능한 패턴: + * - function MyComponent() { return
} + * - const MyComponent = () =>
+ * - const MyComponent = function() { return
} + */ + isBasicComponent(node: Node): boolean { + const hasJsxReturn = + node.getDescendantsOfKind(SyntaxKind.JsxElement).length > 0 || + node.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length > 0 || + node.getDescendantsOfKind(SyntaxKind.JsxFragment).length > 0 + + let name = "" + if (Node.isFunctionDeclaration(node)) { + name = node.getName() || "" + } else if (Node.isVariableDeclaration(node)) { + name = node.getName() + } + + const startsWithCapital = /^[A-Z]/.test(name) + return hasJsxReturn && startsWithCapital + } + + /** + * HOC 패턴 체크 (withXXX(Component) 형태) + * + * 감지 가능한 패턴: + * - const EnhancedComponent = withAuth(BaseComponent) + * - const ProtectedPage = withLayout(PageComponent) + */ + private isHOCWrappedComponent(varDecl: VariableDeclaration): boolean { + const initializer = varDecl.getInitializer() + if (!initializer || !Node.isCallExpression(initializer)) { + return false + } + + const name = varDecl.getName() + const startsWithCapital = /^[A-Z]/.test(name) + + const callExpr = initializer as CallExpression + const expression = callExpr.getExpression() + const hocName = expression.getText() + + return ( + startsWithCapital && + (hocName.startsWith("with") || hocName.startsWith("use")) + ) + } + + /** + * React.memo 체크 + * + * 감지 가능한 패턴: + * - const MemoComponent = React.memo(MyComponent) + * - const MemoComponent = memo(MyComponent) + */ + private isMemoComponent(varDecl: VariableDeclaration): boolean { + const initializer = varDecl.getInitializer() + if (!initializer || !Node.isCallExpression(initializer)) { + return false + } + + const name = varDecl.getName() + const startsWithCapital = /^[A-Z]/.test(name) + + const callExpr = initializer as CallExpression + const expression = callExpr.getExpression() + const text = expression.getText() + + return startsWithCapital && (text === "React.memo" || text === "memo") + } + + /** + * React.forwardRef 체크 + * + * 감지 가능한 패턴: + * - const RefComponent = React.forwardRef((props, ref) =>
) + * - const RefComponent = forwardRef((props, ref) =>
) + */ + private isForwardRefComponent(varDecl: VariableDeclaration): boolean { + const initializer = varDecl.getInitializer() + if (!initializer || !Node.isCallExpression(initializer)) { + return false + } + + const name = varDecl.getName() + const startsWithCapital = /^[A-Z]/.test(name) + + const callExpr = initializer as CallExpression + const expression = callExpr.getExpression() + const text = expression.getText() + + return ( + startsWithCapital && + (text === "React.forwardRef" || text === "forwardRef") + ) + } + + /** + * React.lazy 체크 + * + * 감지 가능한 패턴: + * - const LazyComponent = React.lazy(() => import('./Component')) + * - const LazyComponent = lazy(() => import('./Component')) + */ + private isLazyComponent(varDecl: VariableDeclaration): boolean { + const initializer = varDecl.getInitializer() + if (!initializer || !Node.isCallExpression(initializer)) { + return false + } + + const name = varDecl.getName() + const startsWithCapital = /^[A-Z]/.test(name) + + const callExpr = initializer as CallExpression + const expression = callExpr.getExpression() + const text = expression.getText() + + return startsWithCapital && (text === "React.lazy" || text === "lazy") + } + + /** + * styled-components 체크 + * + * 감지 가능한 패턴: + * - const StyledDiv = styled.div`color: red;` + * - const StyledButton = styled(Button)`padding: 10px;` + * - const StyledDiv = styled.div({ color: 'red' }) + */ + private isStyledComponent(varDecl: VariableDeclaration): boolean { + const initializer = varDecl.getInitializer() + if (!initializer) { + return false + } + + const name = varDecl.getName() + const startsWithCapital = /^[A-Z]/.test(name) + + const text = initializer.getText() + return ( + startsWithCapital && + (text.includes("styled.") || text.includes("styled(")) + ) + } + + /** + * default export 컴포넌트 체크 + * + * 감지 가능한 패턴: + * - export default () =>
+ * - export default function() { return
} + */ + isDefaultExportComponent(node: Node): boolean { + if (!Node.isExportAssignment(node)) { + return false + } + + const expression = node.getExpression() + + if ( + Node.isArrowFunction(expression) || + Node.isFunctionExpression(expression) + ) { + return this.isBasicComponent(expression) + } + + return false + } + + /** + * 변수 선언이 어떤 종류의 컴포넌트인지 판별 + * @returns 컴포넌트 타입 문자열 또는 null + */ + detectComponentKind(varDecl: VariableDeclaration): string | null { + const initializer = varDecl.getInitializer() + + // 기본 함수형 컴포넌트 + if ( + initializer && + (Node.isArrowFunction(initializer) || + Node.isFunctionExpression(initializer)) && + this.isBasicComponent(initializer) + ) { + return "FunctionComponent" + } + + // HOC 패턴 + if (this.isHOCWrappedComponent(varDecl)) { + return "HOC" + } + + // React.memo + if (this.isMemoComponent(varDecl)) { + return "React.memo" + } + + // React.forwardRef + if (this.isForwardRefComponent(varDecl)) { + return "React.forwardRef" + } + + // React.lazy + if (this.isLazyComponent(varDecl)) { + return "React.lazy" + } + + // styled-components + if (this.isStyledComponent(varDecl)) { + return "styled-component" + } + + return null + } +} From 24d8acc5c3bc1f6261d52514230c004ffa0748b9 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 4 Dec 2025 20:56:47 +0900 Subject: [PATCH 02/22] feat(gen-docs): add parseFile function and extract component information from source files --- packages/cli/src/commands/gen-docs/parse.ts | 76 +++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 packages/cli/src/commands/gen-docs/parse.ts diff --git a/packages/cli/src/commands/gen-docs/parse.ts b/packages/cli/src/commands/gen-docs/parse.ts new file mode 100644 index 00000000..69d4b4a5 --- /dev/null +++ b/packages/cli/src/commands/gen-docs/parse.ts @@ -0,0 +1,76 @@ +import { Node, Project } from "ts-morph" + +import { ComponentDetector } from "./componentDetector" + +interface ComponentInfo { + name: string + kind: string + jsDoc: string | null + node: Node +} + +export async function parseFile({ + tsconfigPath, + filePath, +}: { + tsconfigPath: string + filePath: string +}) { + const project = new Project({ tsConfigFilePath: tsconfigPath }) + + const sourceFile = project.getSourceFileOrThrow(filePath) + const components: ComponentInfo[] = [] + const detector = new ComponentDetector() + + // 1. 함수 선언 컴포넌트 + sourceFile.getFunctions().forEach((func) => { + if (detector.isBasicComponent(func)) { + const jsDocComments = func.getJsDocs() + const jsDocText = + jsDocComments.length > 0 + ? jsDocComments.map((doc) => doc.getFullText()).join("\n") + : null + + components.push({ + name: func.getName() || "Anonymous", + kind: "FunctionDeclaration", + jsDoc: jsDocText, + node: func, + }) + } + }) + + // 2. 변수 선언 컴포넌트 + sourceFile.getVariableDeclarations().forEach((varDecl) => { + const name = varDecl.getName() + const componentKind = detector.detectComponentKind(varDecl) + + if (componentKind) { + const variableStatement = varDecl.getVariableStatement() + const jsDocComments = variableStatement?.getJsDocs() || [] + const jsDocText = + jsDocComments.length > 0 + ? jsDocComments.map((doc) => doc.getFullText()).join("\n") + : null + + components.push({ + name: name, + kind: componentKind, + jsDoc: jsDocText, + node: varDecl, + }) + } + }) + + // 4. default export + sourceFile.getExportAssignments().forEach((exportAssignment) => { + if (detector.isDefaultExportComponent(exportAssignment)) { + components.push({ + name: "default", + kind: "DefaultExport", + jsDoc: null, + node: exportAssignment, + }) + } + }) +} From 57f63d2655aeb89c8e4e1b8775600a42a228e9b5 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Thu, 4 Dec 2025 22:31:08 +0900 Subject: [PATCH 03/22] chore(gen-docs): move location --- .../commands/gen-docs/componentDetector.ts | 232 ------------------ packages/cli/src/commands/gen-docs/parse.ts | 76 ------ 2 files changed, 308 deletions(-) delete mode 100644 packages/cli/src/commands/gen-docs/componentDetector.ts delete mode 100644 packages/cli/src/commands/gen-docs/parse.ts diff --git a/packages/cli/src/commands/gen-docs/componentDetector.ts b/packages/cli/src/commands/gen-docs/componentDetector.ts deleted file mode 100644 index f2b34b66..00000000 --- a/packages/cli/src/commands/gen-docs/componentDetector.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { - type CallExpression, - Node, - SyntaxKind, - type VariableDeclaration, -} from "ts-morph" - -/** - * React 컴포넌트 감지 클래스 - * 다양한 React 컴포넌트 패턴을 식별하는 유틸리티 - */ -export class ComponentDetector { - /** - * 기본 함수/화살표 함수 컴포넌트 체크 - * - * 판단 기준: - * - JSX 요소를 반환하는가? (JsxElement, JsxSelfClosingElement, JsxFragment) - * - 함수명이 대문자로 시작하는가? (React 컴포넌트 네이밍 규칙) - * - * 감지 가능한 패턴: - * - function MyComponent() { return
} - * - const MyComponent = () =>
- * - const MyComponent = function() { return
} - */ - isBasicComponent(node: Node): boolean { - const hasJsxReturn = - node.getDescendantsOfKind(SyntaxKind.JsxElement).length > 0 || - node.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length > 0 || - node.getDescendantsOfKind(SyntaxKind.JsxFragment).length > 0 - - let name = "" - if (Node.isFunctionDeclaration(node)) { - name = node.getName() || "" - } else if (Node.isVariableDeclaration(node)) { - name = node.getName() - } - - const startsWithCapital = /^[A-Z]/.test(name) - return hasJsxReturn && startsWithCapital - } - - /** - * HOC 패턴 체크 (withXXX(Component) 형태) - * - * 감지 가능한 패턴: - * - const EnhancedComponent = withAuth(BaseComponent) - * - const ProtectedPage = withLayout(PageComponent) - */ - private isHOCWrappedComponent(varDecl: VariableDeclaration): boolean { - const initializer = varDecl.getInitializer() - if (!initializer || !Node.isCallExpression(initializer)) { - return false - } - - const name = varDecl.getName() - const startsWithCapital = /^[A-Z]/.test(name) - - const callExpr = initializer as CallExpression - const expression = callExpr.getExpression() - const hocName = expression.getText() - - return ( - startsWithCapital && - (hocName.startsWith("with") || hocName.startsWith("use")) - ) - } - - /** - * React.memo 체크 - * - * 감지 가능한 패턴: - * - const MemoComponent = React.memo(MyComponent) - * - const MemoComponent = memo(MyComponent) - */ - private isMemoComponent(varDecl: VariableDeclaration): boolean { - const initializer = varDecl.getInitializer() - if (!initializer || !Node.isCallExpression(initializer)) { - return false - } - - const name = varDecl.getName() - const startsWithCapital = /^[A-Z]/.test(name) - - const callExpr = initializer as CallExpression - const expression = callExpr.getExpression() - const text = expression.getText() - - return startsWithCapital && (text === "React.memo" || text === "memo") - } - - /** - * React.forwardRef 체크 - * - * 감지 가능한 패턴: - * - const RefComponent = React.forwardRef((props, ref) =>
) - * - const RefComponent = forwardRef((props, ref) =>
) - */ - private isForwardRefComponent(varDecl: VariableDeclaration): boolean { - const initializer = varDecl.getInitializer() - if (!initializer || !Node.isCallExpression(initializer)) { - return false - } - - const name = varDecl.getName() - const startsWithCapital = /^[A-Z]/.test(name) - - const callExpr = initializer as CallExpression - const expression = callExpr.getExpression() - const text = expression.getText() - - return ( - startsWithCapital && - (text === "React.forwardRef" || text === "forwardRef") - ) - } - - /** - * React.lazy 체크 - * - * 감지 가능한 패턴: - * - const LazyComponent = React.lazy(() => import('./Component')) - * - const LazyComponent = lazy(() => import('./Component')) - */ - private isLazyComponent(varDecl: VariableDeclaration): boolean { - const initializer = varDecl.getInitializer() - if (!initializer || !Node.isCallExpression(initializer)) { - return false - } - - const name = varDecl.getName() - const startsWithCapital = /^[A-Z]/.test(name) - - const callExpr = initializer as CallExpression - const expression = callExpr.getExpression() - const text = expression.getText() - - return startsWithCapital && (text === "React.lazy" || text === "lazy") - } - - /** - * styled-components 체크 - * - * 감지 가능한 패턴: - * - const StyledDiv = styled.div`color: red;` - * - const StyledButton = styled(Button)`padding: 10px;` - * - const StyledDiv = styled.div({ color: 'red' }) - */ - private isStyledComponent(varDecl: VariableDeclaration): boolean { - const initializer = varDecl.getInitializer() - if (!initializer) { - return false - } - - const name = varDecl.getName() - const startsWithCapital = /^[A-Z]/.test(name) - - const text = initializer.getText() - return ( - startsWithCapital && - (text.includes("styled.") || text.includes("styled(")) - ) - } - - /** - * default export 컴포넌트 체크 - * - * 감지 가능한 패턴: - * - export default () =>
- * - export default function() { return
} - */ - isDefaultExportComponent(node: Node): boolean { - if (!Node.isExportAssignment(node)) { - return false - } - - const expression = node.getExpression() - - if ( - Node.isArrowFunction(expression) || - Node.isFunctionExpression(expression) - ) { - return this.isBasicComponent(expression) - } - - return false - } - - /** - * 변수 선언이 어떤 종류의 컴포넌트인지 판별 - * @returns 컴포넌트 타입 문자열 또는 null - */ - detectComponentKind(varDecl: VariableDeclaration): string | null { - const initializer = varDecl.getInitializer() - - // 기본 함수형 컴포넌트 - if ( - initializer && - (Node.isArrowFunction(initializer) || - Node.isFunctionExpression(initializer)) && - this.isBasicComponent(initializer) - ) { - return "FunctionComponent" - } - - // HOC 패턴 - if (this.isHOCWrappedComponent(varDecl)) { - return "HOC" - } - - // React.memo - if (this.isMemoComponent(varDecl)) { - return "React.memo" - } - - // React.forwardRef - if (this.isForwardRefComponent(varDecl)) { - return "React.forwardRef" - } - - // React.lazy - if (this.isLazyComponent(varDecl)) { - return "React.lazy" - } - - // styled-components - if (this.isStyledComponent(varDecl)) { - return "styled-component" - } - - return null - } -} diff --git a/packages/cli/src/commands/gen-docs/parse.ts b/packages/cli/src/commands/gen-docs/parse.ts deleted file mode 100644 index 69d4b4a5..00000000 --- a/packages/cli/src/commands/gen-docs/parse.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Node, Project } from "ts-morph" - -import { ComponentDetector } from "./componentDetector" - -interface ComponentInfo { - name: string - kind: string - jsDoc: string | null - node: Node -} - -export async function parseFile({ - tsconfigPath, - filePath, -}: { - tsconfigPath: string - filePath: string -}) { - const project = new Project({ tsConfigFilePath: tsconfigPath }) - - const sourceFile = project.getSourceFileOrThrow(filePath) - const components: ComponentInfo[] = [] - const detector = new ComponentDetector() - - // 1. 함수 선언 컴포넌트 - sourceFile.getFunctions().forEach((func) => { - if (detector.isBasicComponent(func)) { - const jsDocComments = func.getJsDocs() - const jsDocText = - jsDocComments.length > 0 - ? jsDocComments.map((doc) => doc.getFullText()).join("\n") - : null - - components.push({ - name: func.getName() || "Anonymous", - kind: "FunctionDeclaration", - jsDoc: jsDocText, - node: func, - }) - } - }) - - // 2. 변수 선언 컴포넌트 - sourceFile.getVariableDeclarations().forEach((varDecl) => { - const name = varDecl.getName() - const componentKind = detector.detectComponentKind(varDecl) - - if (componentKind) { - const variableStatement = varDecl.getVariableStatement() - const jsDocComments = variableStatement?.getJsDocs() || [] - const jsDocText = - jsDocComments.length > 0 - ? jsDocComments.map((doc) => doc.getFullText()).join("\n") - : null - - components.push({ - name: name, - kind: componentKind, - jsDoc: jsDocText, - node: varDecl, - }) - } - }) - - // 4. default export - sourceFile.getExportAssignments().forEach((exportAssignment) => { - if (detector.isDefaultExportComponent(exportAssignment)) { - components.push({ - name: "default", - kind: "DefaultExport", - jsDoc: null, - node: exportAssignment, - }) - } - }) -} From 1727ca31f741d2f9c79658ab7ae9ccca94ce36c8 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Sat, 7 Feb 2026 15:35:23 +0900 Subject: [PATCH 04/22] chore: remove figma packages - not used --- figma/mcp/package.json | 48 - figma/mcp/scripts/setup.sh | 19 - figma/mcp/src/cursor_mcp_plugin/code.js | 1233 ---------------- figma/mcp/src/cursor_mcp_plugin/manifest.json | 16 - .../src/cursor_mcp_plugin/setcharacters.js | 215 --- figma/mcp/src/cursor_mcp_plugin/ui.html | 553 -------- figma/mcp/src/socket.ts | 185 --- figma/mcp/src/talk_to_figma_mcp/server.ts | 1253 ----------------- figma/mcp/tsconfig.json | 17 - figma/mcp/tsup.config.ts | 14 - figma/plugin/eslint.config.mjs | 11 - figma/plugin/package.json | 65 - figma/plugin/src/App.css | 30 - figma/plugin/src/app.tsx | 303 ---- figma/plugin/src/main.ts | 412 ------ figma/plugin/src/server.ts | 363 ----- figma/plugin/src/socket.ts | 180 --- figma/plugin/src/styles.css | 51 - figma/plugin/src/ui.tsx | 12 - figma/plugin/src/utils/convertNodeToXML.ts | 155 -- figma/plugin/src/utils/map.ts | 244 ---- figma/plugin/src/utils/node.ts | 502 ------- figma/plugin/src/vite-env.d.ts | 1 - figma/plugin/tsconfig.json | 27 - figma/plugin/tsup.config.ts | 10 - figma/plugin/vite.config.js | 9 - 26 files changed, 5928 deletions(-) delete mode 100644 figma/mcp/package.json delete mode 100755 figma/mcp/scripts/setup.sh delete mode 100644 figma/mcp/src/cursor_mcp_plugin/code.js delete mode 100644 figma/mcp/src/cursor_mcp_plugin/manifest.json delete mode 100644 figma/mcp/src/cursor_mcp_plugin/setcharacters.js delete mode 100644 figma/mcp/src/cursor_mcp_plugin/ui.html delete mode 100644 figma/mcp/src/socket.ts delete mode 100644 figma/mcp/src/talk_to_figma_mcp/server.ts delete mode 100644 figma/mcp/tsconfig.json delete mode 100644 figma/mcp/tsup.config.ts delete mode 100644 figma/plugin/eslint.config.mjs delete mode 100644 figma/plugin/package.json delete mode 100644 figma/plugin/src/App.css delete mode 100644 figma/plugin/src/app.tsx delete mode 100644 figma/plugin/src/main.ts delete mode 100644 figma/plugin/src/server.ts delete mode 100644 figma/plugin/src/socket.ts delete mode 100644 figma/plugin/src/styles.css delete mode 100644 figma/plugin/src/ui.tsx delete mode 100644 figma/plugin/src/utils/convertNodeToXML.ts delete mode 100644 figma/plugin/src/utils/map.ts delete mode 100644 figma/plugin/src/utils/node.ts delete mode 100644 figma/plugin/src/vite-env.d.ts delete mode 100644 figma/plugin/tsconfig.json delete mode 100644 figma/plugin/tsup.config.ts delete mode 100644 figma/plugin/vite.config.js diff --git a/figma/mcp/package.json b/figma/mcp/package.json deleted file mode 100644 index 8bb41d6c..00000000 --- a/figma/mcp/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "cursor-figma-mcp-jh", - "description": "my figma mcp server using cursor-talk-to-figma-mcp", - "version": "0.0.1", - "module": "dist/server.js", - "main": "dist/server.js", - "bin": { - "cursor-talk-to-figma-mcp": "dist/server.js" - }, - "files": [ - "dist", - "README.md" - ], - "type": "module", - "scripts": { - "start": "node dist/server.js", - "socket": "npx tsx src/socket.ts", - "setup": "./scripts/setup.sh", - "build": "tsup", - "build:watch": "tsup --watch", - "dev": "pnpm run build:watch", - "pub:release": "pnpm run build && pnpm publish", - "clean": "rm -rf dist node_modules" - }, - "devDependencies": { - "@figma/plugin-typings": "^1.110.0", - "@figma/rest-api-spec": "^0.29.0", - "ts-node": "^10.9.2", - "tsup": "^8.4.0", - "@typescript/native-preview": "latest" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "latest", - "@types/react": "^18.2.55", - "@types/styled-components": "^5.1.34", - "@types/ws": "^8.5.10", - "figma-api": "2.0.2-beta", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "styled-components": "^6.1.8", - "uuid": "latest", - "ws": "latest", - "zod": "latest" - }, - "engines": { - "node": ">=18" - } -} diff --git a/figma/mcp/scripts/setup.sh b/figma/mcp/scripts/setup.sh deleted file mode 100755 index 37cbcb57..00000000 --- a/figma/mcp/scripts/setup.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# Create .cursor directory if it doesn't exist -mkdir -p .cursor - -# Install dependencies with pnpm -pnpm install - -# Create mcp.json with the current directory path -echo "{ - \"mcpServers\": { - \"TalkToFigma\": { - \"command\": \"npx\", - \"args\": [ - \"cursor-talk-to-figma-mcp\" - ] - } - } -}" > .cursor/mcp.json \ No newline at end of file diff --git a/figma/mcp/src/cursor_mcp_plugin/code.js b/figma/mcp/src/cursor_mcp_plugin/code.js deleted file mode 100644 index 00ac806c..00000000 --- a/figma/mcp/src/cursor_mcp_plugin/code.js +++ /dev/null @@ -1,1233 +0,0 @@ -// This is the main code file for the Cursor MCP Figma plugin -// It handles Figma API commands - -// Plugin state -const state = { - serverPort: 3055, // Default port -} - -// Show UI -figma.showUI(__html__, { width: 350, height: 450 }) - -// Plugin commands from UI -figma.ui.onmessage = async (msg) => { - switch (msg.type) { - case "update-settings": - updateSettings(msg) - break - case "notify": - figma.notify(msg.message) - break - case "close-plugin": - figma.closePlugin() - break - case "execute-command": - // Execute commands received from UI (which gets them from WebSocket) - try { - const result = await handleCommand(msg.command, msg.params) - // Send result back to UI - figma.ui.postMessage({ - type: "command-result", - id: msg.id, - result, - }) - } catch (error) { - figma.ui.postMessage({ - type: "command-error", - id: msg.id, - error: error.message || "Error executing command", - }) - } - break - } -} - -// Listen for plugin commands from menu -figma.on("run", ({ command }) => { - figma.ui.postMessage({ type: "auto-connect" }) -}) - -// Update plugin settings -function updateSettings(settings) { - if (settings.serverPort) { - state.serverPort = settings.serverPort - } - - figma.clientStorage.setAsync("settings", { - serverPort: state.serverPort, - }) -} - -// Handle commands from UI -async function handleCommand(command, params) { - switch (command) { - case "get_document_info": - return await getDocumentInfo() - case "get_selection": - return await getSelection() - case "get_node_info": - if (!params || !params.nodeId) { - throw new Error("Missing nodeId parameter") - } - return await getNodeInfo(params.nodeId) - case "get_nodes_info": - if (!params || !params.nodeIds || !Array.isArray(params.nodeIds)) { - throw new Error("Missing or invalid nodeIds parameter") - } - return await getNodesInfo(params.nodeIds) - case "create_rectangle": - return await createRectangle(params) - case "create_frame": - return await createFrame(params) - case "create_text": - return await createText(params) - case "set_fill_color": - return await setFillColor(params) - case "set_stroke_color": - return await setStrokeColor(params) - case "move_node": - return await moveNode(params) - case "resize_node": - return await resizeNode(params) - case "delete_node": - return await deleteNode(params) - case "get_styles": - return await getStyles() - case "get_local_components": - return await getLocalComponents() - // case "get_team_components": - // return await getTeamComponents(); - case "create_component_instance": - return await createComponentInstance(params) - case "export_node_as_image": - return await exportNodeAsImage(params) - case "set_corner_radius": - return await setCornerRadius(params) - case "set_text_content": - return await setTextContent(params) - case "clone_node": - return await cloneNode(params) - default: - throw new Error(`Unknown command: ${command}`) - } -} - -async function getDocumentInfo() { - await figma.currentPage.loadAsync() - const page = figma.currentPage - return { - name: page.name, - id: page.id, - type: page.type, - children: page.children.map((node) => ({ - id: node.id, - name: node.name, - type: node.type, - })), - currentPage: { - id: page.id, - name: page.name, - childCount: page.children.length, - }, - pages: [ - { - id: page.id, - name: page.name, - childCount: page.children.length, - }, - ], - } -} - -async function getSelection() { - const selection = figma.currentPage.selection - const selectionInfo = { - selectionCount: selection.length, - selection: selection.map((node) => ({ - id: node.id, - name: node.name, - type: node.type, - visible: node.visible, - })), - } - - // 선택된 노드가 있는 경우 스타일 추출 - if (selection.length > 0) { - try { - const firstNode = selection[0] - const nodeName = firstNode.name.toLowerCase() - - // 노드 스타일 추출 함수 - const extractNodeStyles = async (node) => { - const styles = {} - - // 기본 스타일 추출 - try { - const css = await node.getCSSAsync() - Object.assign(styles, css) - } catch (e) { - console.error("CSS 추출 오류:", e) - } - - // 자식 노드 스타일 추출 - const childrenStyles = [] - if ("children" in node && node.children && node.children.length > 0) { - for (const child of node.children) { - const childStyle = await extractNodeStyles(child) - childrenStyles.push({ - id: child.id, - name: child.name, - type: child.type, - styles: childStyle, - }) - } - } - - return { - css: styles, - children: childrenStyles.length > 0 ? childrenStyles : undefined, - } - } - - // 스타일 추출 및 저장 - const nodeStyles = await extractNodeStyles(firstNode) - selectionInfo.styles = { - nodeName, - nodeStyles, - } - } catch (error) { - console.error("스타일 추출 중 오류 발생:", error) - } - } - - return selectionInfo -} - -async function getNodeInfo(nodeId) { - const node = await figma.getNodeByIdAsync(nodeId) - - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - const response = await node.exportAsync({ - format: "JSON_REST_V1", - }) - - return response.document -} - -async function getNodesInfo(nodeIds) { - try { - // Load all nodes in parallel - const nodes = await Promise.all( - nodeIds.map((id) => figma.getNodeByIdAsync(id)), - ) - - // Filter out any null values (nodes that weren't found) - const validNodes = nodes.filter((node) => node !== null) - - // Export all valid nodes in parallel - const responses = await Promise.all( - validNodes.map(async (node) => { - const response = await node.exportAsync({ - format: "JSON_REST_V1", - }) - return { - nodeId: node.id, - document: response.document, - } - }), - ) - - return responses - } catch (error) { - throw new Error(`Error getting nodes info: ${error.message}`) - } -} - -async function createRectangle(params) { - const { - x = 0, - y = 0, - width = 100, - height = 100, - name = "Rectangle", - parentId, - } = params || {} - - const rect = figma.createRectangle() - rect.x = x - rect.y = y - rect.resize(width, height) - rect.name = name - - // If parentId is provided, append to that node, otherwise append to current page - if (parentId) { - const parentNode = await figma.getNodeByIdAsync(parentId) - if (!parentNode) { - throw new Error(`Parent node not found with ID: ${parentId}`) - } - if (!("appendChild" in parentNode)) { - throw new Error(`Parent node does not support children: ${parentId}`) - } - parentNode.appendChild(rect) - } else { - figma.currentPage.appendChild(rect) - } - - return { - id: rect.id, - name: rect.name, - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - parentId: rect.parent ? rect.parent.id : undefined, - } -} - -async function createFrame(params) { - const { - x = 0, - y = 0, - width = 100, - height = 100, - name = "Frame", - parentId, - fillColor, - strokeColor, - strokeWeight, - } = params || {} - - const frame = figma.createFrame() - frame.x = x - frame.y = y - frame.resize(width, height) - frame.name = name - - // Set fill color if provided - if (fillColor) { - const paintStyle = { - type: "SOLID", - color: { - r: parseFloat(fillColor.r) || 0, - g: parseFloat(fillColor.g) || 0, - b: parseFloat(fillColor.b) || 0, - }, - opacity: parseFloat(fillColor.a) || 1, - } - frame.fills = [paintStyle] - } - - // Set stroke color and weight if provided - if (strokeColor) { - const strokeStyle = { - type: "SOLID", - color: { - r: parseFloat(strokeColor.r) || 0, - g: parseFloat(strokeColor.g) || 0, - b: parseFloat(strokeColor.b) || 0, - }, - opacity: parseFloat(strokeColor.a) || 1, - } - frame.strokes = [strokeStyle] - } - - // Set stroke weight if provided - if (strokeWeight !== undefined) { - frame.strokeWeight = strokeWeight - } - - // If parentId is provided, append to that node, otherwise append to current page - if (parentId) { - const parentNode = await figma.getNodeByIdAsync(parentId) - if (!parentNode) { - throw new Error(`Parent node not found with ID: ${parentId}`) - } - if (!("appendChild" in parentNode)) { - throw new Error(`Parent node does not support children: ${parentId}`) - } - parentNode.appendChild(frame) - } else { - figma.currentPage.appendChild(frame) - } - - return { - id: frame.id, - name: frame.name, - x: frame.x, - y: frame.y, - width: frame.width, - height: frame.height, - fills: frame.fills, - strokes: frame.strokes, - strokeWeight: frame.strokeWeight, - parentId: frame.parent ? frame.parent.id : undefined, - } -} - -async function createText(params) { - const { - x = 0, - y = 0, - text = "Text", - fontSize = 14, - fontWeight = 400, - fontColor = { r: 0, g: 0, b: 0, a: 1 }, // Default to black - name = "Text", - parentId, - } = params || {} - - // Map common font weights to Figma font styles - const getFontStyle = (weight) => { - switch (weight) { - case 100: - return "Thin" - case 200: - return "Extra Light" - case 300: - return "Light" - case 400: - return "Regular" - case 500: - return "Medium" - case 600: - return "Semi Bold" - case 700: - return "Bold" - case 800: - return "Extra Bold" - case 900: - return "Black" - default: - return "Regular" - } - } - - const textNode = figma.createText() - textNode.x = x - textNode.y = y - textNode.name = name - try { - await figma.loadFontAsync({ - family: "Inter", - style: getFontStyle(fontWeight), - }) - textNode.fontName = { family: "Inter", style: getFontStyle(fontWeight) } - textNode.fontSize = parseInt(fontSize) - } catch (error) { - console.error("Error setting font size", error) - } - setCharacters(textNode, text) - - // Set text color - const paintStyle = { - type: "SOLID", - color: { - r: parseFloat(fontColor.r) || 0, - g: parseFloat(fontColor.g) || 0, - b: parseFloat(fontColor.b) || 0, - }, - opacity: parseFloat(fontColor.a) || 1, - } - textNode.fills = [paintStyle] - - // If parentId is provided, append to that node, otherwise append to current page - if (parentId) { - const parentNode = await figma.getNodeByIdAsync(parentId) - if (!parentNode) { - throw new Error(`Parent node not found with ID: ${parentId}`) - } - if (!("appendChild" in parentNode)) { - throw new Error(`Parent node does not support children: ${parentId}`) - } - parentNode.appendChild(textNode) - } else { - figma.currentPage.appendChild(textNode) - } - - return { - id: textNode.id, - name: textNode.name, - x: textNode.x, - y: textNode.y, - width: textNode.width, - height: textNode.height, - characters: textNode.characters, - fontSize: textNode.fontSize, - fontWeight: fontWeight, - fontColor: fontColor, - fontName: textNode.fontName, - fills: textNode.fills, - parentId: textNode.parent ? textNode.parent.id : undefined, - } -} - -async function setFillColor(params) { - console.log("setFillColor", params) - const { - nodeId, - color: { r, g, b, a }, - } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - if (!("fills" in node)) { - throw new Error(`Node does not support fills: ${nodeId}`) - } - - // Create RGBA color - const rgbColor = { - r: parseFloat(r) || 0, - g: parseFloat(g) || 0, - b: parseFloat(b) || 0, - a: parseFloat(a) || 1, - } - - // Set fill - const paintStyle = { - type: "SOLID", - color: { - r: parseFloat(rgbColor.r), - g: parseFloat(rgbColor.g), - b: parseFloat(rgbColor.b), - }, - opacity: parseFloat(rgbColor.a), - } - - console.log("paintStyle", paintStyle) - - node.fills = [paintStyle] - - return { - id: node.id, - name: node.name, - fills: [paintStyle], - } -} - -async function setStrokeColor(params) { - const { - nodeId, - color: { r, g, b, a }, - weight = 1, - } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - if (!("strokes" in node)) { - throw new Error(`Node does not support strokes: ${nodeId}`) - } - - // Create RGBA color - const rgbColor = { - r: r !== undefined ? r : 0, - g: g !== undefined ? g : 0, - b: b !== undefined ? b : 0, - a: a !== undefined ? a : 1, - } - - // Set stroke - const paintStyle = { - type: "SOLID", - color: { - r: rgbColor.r, - g: rgbColor.g, - b: rgbColor.b, - }, - opacity: rgbColor.a, - } - - node.strokes = [paintStyle] - - // Set stroke weight if available - if ("strokeWeight" in node) { - node.strokeWeight = weight - } - - return { - id: node.id, - name: node.name, - strokes: node.strokes, - strokeWeight: "strokeWeight" in node ? node.strokeWeight : undefined, - } -} - -async function moveNode(params) { - const { nodeId, x, y } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - if (x === undefined || y === undefined) { - throw new Error("Missing x or y parameters") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - if (!("x" in node) || !("y" in node)) { - throw new Error(`Node does not support position: ${nodeId}`) - } - - node.x = x - node.y = y - - return { - id: node.id, - name: node.name, - x: node.x, - y: node.y, - } -} - -async function resizeNode(params) { - const { nodeId, width, height } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - if (width === undefined || height === undefined) { - throw new Error("Missing width or height parameters") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - if (!("resize" in node)) { - throw new Error(`Node does not support resizing: ${nodeId}`) - } - - node.resize(width, height) - - return { - id: node.id, - name: node.name, - width: node.width, - height: node.height, - } -} - -async function deleteNode(params) { - const { nodeId } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - // Save node info before deleting - const nodeInfo = { - id: node.id, - name: node.name, - type: node.type, - } - - node.remove() - - return nodeInfo -} - -async function getStyles() { - const styles = { - colors: await figma.getLocalPaintStylesAsync(), - texts: await figma.getLocalTextStylesAsync(), - effects: await figma.getLocalEffectStylesAsync(), - grids: await figma.getLocalGridStylesAsync(), - } - - return { - colors: styles.colors.map((style) => ({ - id: style.id, - name: style.name, - key: style.key, - paint: style.paints[0], - })), - texts: styles.texts.map((style) => ({ - id: style.id, - name: style.name, - key: style.key, - fontSize: style.fontSize, - fontName: style.fontName, - })), - effects: styles.effects.map((style) => ({ - id: style.id, - name: style.name, - key: style.key, - })), - grids: styles.grids.map((style) => ({ - id: style.id, - name: style.name, - key: style.key, - })), - } -} - -async function getLocalComponents() { - await figma.loadAllPagesAsync() - - const components = figma.root.findAllWithCriteria({ - types: ["COMPONENT"], - }) - - return { - count: components.length, - components: components.map((component) => ({ - id: component.id, - name: component.name, - key: "key" in component ? component.key : null, - })), - } -} - -// async function getTeamComponents() { -// try { -// const teamComponents = -// await figma.teamLibrary.getAvailableComponentsAsync(); - -// return { -// count: teamComponents.length, -// components: teamComponents.map((component) => ({ -// key: component.key, -// name: component.name, -// description: component.description, -// libraryName: component.libraryName, -// })), -// }; -// } catch (error) { -// throw new Error(`Error getting team components: ${error.message}`); -// } -// } - -async function createComponentInstance(params) { - const { componentKey, x = 0, y = 0 } = params || {} - - if (!componentKey) { - throw new Error("Missing componentKey parameter") - } - - try { - const component = await figma.importComponentByKeyAsync(componentKey) - const instance = component.createInstance() - - instance.x = x - instance.y = y - - figma.currentPage.appendChild(instance) - - return { - id: instance.id, - name: instance.name, - x: instance.x, - y: instance.y, - width: instance.width, - height: instance.height, - componentId: instance.componentId, - } - } catch (error) { - throw new Error(`Error creating component instance: ${error.message}`) - } -} - -async function exportNodeAsImage(params) { - const { nodeId, scale = 1 } = params || {} - - const format = "PNG" - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - if (!("exportAsync" in node)) { - throw new Error(`Node does not support exporting: ${nodeId}`) - } - - try { - const settings = { - format: format, - constraint: { type: "SCALE", value: scale }, - } - - const bytes = await node.exportAsync(settings) - - let mimeType - switch (format) { - case "PNG": - mimeType = "image/png" - break - case "JPG": - mimeType = "image/jpeg" - break - case "SVG": - mimeType = "image/svg+xml" - break - case "PDF": - mimeType = "application/pdf" - break - default: - mimeType = "application/octet-stream" - } - - // Proper way to convert Uint8Array to base64 - const base64 = customBase64Encode(bytes) - // const imageData = `data:${mimeType};base64,${base64}`; - - return { - nodeId, - format, - scale, - mimeType, - imageData: base64, - } - } catch (error) { - throw new Error(`Error exporting node as image: ${error.message}`) - } -} -function customBase64Encode(bytes) { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - let base64 = "" - - const byteLength = bytes.byteLength - const byteRemainder = byteLength % 3 - const mainLength = byteLength - byteRemainder - - let a, b, c, d - let chunk - - // Main loop deals with bytes in chunks of 3 - for (let i = 0; i < mainLength; i = i + 3) { - // Combine the three bytes into a single integer - chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2] - - // Use bitmasks to extract 6-bit segments from the triplet - a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18 - b = (chunk & 258048) >> 12 // 258048 = (2^6 - 1) << 12 - c = (chunk & 4032) >> 6 // 4032 = (2^6 - 1) << 6 - d = chunk & 63 // 63 = 2^6 - 1 - - // Convert the raw binary segments to the appropriate ASCII encoding - base64 += chars[a] + chars[b] + chars[c] + chars[d] - } - - // Deal with the remaining bytes and padding - if (byteRemainder === 1) { - chunk = bytes[mainLength] - - a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2 - - // Set the 4 least significant bits to zero - b = (chunk & 3) << 4 // 3 = 2^2 - 1 - - base64 += chars[a] + chars[b] + "==" - } else if (byteRemainder === 2) { - chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1] - - a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10 - b = (chunk & 1008) >> 4 // 1008 = (2^6 - 1) << 4 - - // Set the 2 least significant bits to zero - c = (chunk & 15) << 2 // 15 = 2^4 - 1 - - base64 += chars[a] + chars[b] + chars[c] + "=" - } - - return base64 -} - -async function setCornerRadius(params) { - const { nodeId, radius, corners } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - if (radius === undefined) { - throw new Error("Missing radius parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - // Check if node supports corner radius - if (!("cornerRadius" in node)) { - throw new Error(`Node does not support corner radius: ${nodeId}`) - } - - // If corners array is provided, set individual corner radii - if (corners && Array.isArray(corners) && corners.length === 4) { - if ("topLeftRadius" in node) { - // Node supports individual corner radii - if (corners[0]) node.topLeftRadius = radius - if (corners[1]) node.topRightRadius = radius - if (corners[2]) node.bottomRightRadius = radius - if (corners[3]) node.bottomLeftRadius = radius - } else { - // Node only supports uniform corner radius - node.cornerRadius = radius - } - } else { - // Set uniform corner radius - node.cornerRadius = radius - } - - return { - id: node.id, - name: node.name, - cornerRadius: "cornerRadius" in node ? node.cornerRadius : undefined, - topLeftRadius: "topLeftRadius" in node ? node.topLeftRadius : undefined, - topRightRadius: "topRightRadius" in node ? node.topRightRadius : undefined, - bottomRightRadius: - "bottomRightRadius" in node ? node.bottomRightRadius : undefined, - bottomLeftRadius: - "bottomLeftRadius" in node ? node.bottomLeftRadius : undefined, - } -} - -async function setTextContent(params) { - const { nodeId, text } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - if (text === undefined) { - throw new Error("Missing text parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - if (node.type !== "TEXT") { - throw new Error(`Node is not a text node: ${nodeId}`) - } - - try { - await figma.loadFontAsync(node.fontName) - - await setCharacters(node, text) - - return { - id: node.id, - name: node.name, - characters: node.characters, - fontName: node.fontName, - } - } catch (error) { - throw new Error(`Error setting text content: ${error.message}`) - } -} - -// Initialize settings on load -;(async function initializePlugin() { - try { - const savedSettings = await figma.clientStorage.getAsync("settings") - if (savedSettings) { - if (savedSettings.serverPort) { - state.serverPort = savedSettings.serverPort - } - } - - // Send initial settings to UI - figma.ui.postMessage({ - type: "init-settings", - settings: { - serverPort: state.serverPort, - }, - }) - } catch (error) { - console.error("Error loading settings:", error) - } -})() - -function uniqBy(arr, predicate) { - const cb = typeof predicate === "function" ? predicate : (o) => o[predicate] - return [ - ...arr - .reduce((map, item) => { - const key = item === null || item === undefined ? item : cb(item) - - map.has(key) || map.set(key, item) - - return map - }, new Map()) - .values(), - ] -} -const setCharacters = async (node, characters, options) => { - const fallbackFont = (options && options.fallbackFont) || { - family: "Inter", - style: "Regular", - } - try { - if (node.fontName === figma.mixed) { - if (options && options.smartStrategy === "prevail") { - const fontHashTree = {} - for (let i = 1; i < node.characters.length; i++) { - const charFont = node.getRangeFontName(i - 1, i) - const key = `${charFont.family}::${charFont.style}` - fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1 - } - const prevailedTreeItem = Object.entries(fontHashTree).sort( - (a, b) => b[1] - a[1], - )[0] - const [family, style] = prevailedTreeItem[0].split("::") - const prevailedFont = { - family, - style, - } - await figma.loadFontAsync(prevailedFont) - node.fontName = prevailedFont - } else if (options && options.smartStrategy === "strict") { - return setCharactersWithStrictMatchFont(node, characters, fallbackFont) - } else if (options && options.smartStrategy === "experimental") { - return setCharactersWithSmartMatchFont(node, characters, fallbackFont) - } else { - const firstCharFont = node.getRangeFontName(0, 1) - await figma.loadFontAsync(firstCharFont) - node.fontName = firstCharFont - } - } else { - await figma.loadFontAsync({ - family: node.fontName.family, - style: node.fontName.style, - }) - } - } catch (err) { - console.warn( - `Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`, - err, - ) - await figma.loadFontAsync(fallbackFont) - node.fontName = fallbackFont - } - try { - node.characters = characters - return true - } catch (err) { - console.warn(`Failed to set characters. Skipped.`, err) - return false - } -} - -const setCharactersWithStrictMatchFont = async ( - node, - characters, - fallbackFont, -) => { - const fontHashTree = {} - for (let i = 1; i < node.characters.length; i++) { - const startIdx = i - 1 - const startCharFont = node.getRangeFontName(startIdx, i) - const startCharFontVal = `${startCharFont.family}::${startCharFont.style}` - while (i < node.characters.length) { - i++ - const charFont = node.getRangeFontName(i - 1, i) - if (startCharFontVal !== `${charFont.family}::${charFont.style}`) { - break - } - } - fontHashTree[`${startIdx}_${i}`] = startCharFontVal - } - await figma.loadFontAsync(fallbackFont) - node.fontName = fallbackFont - node.characters = characters - console.log(fontHashTree) - await Promise.all( - Object.keys(fontHashTree).map(async (range) => { - console.log(range, fontHashTree[range]) - const [start, end] = range.split("_") - const [family, style] = fontHashTree[range].split("::") - const matchedFont = { - family, - style, - } - await figma.loadFontAsync(matchedFont) - return node.setRangeFontName(Number(start), Number(end), matchedFont) - }), - ) - return true -} - -const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => { - const indices = [] - let temp = startIdx - for (let i = startIdx; i < endIdx; i++) { - if ( - str[i] === delimiter && - i + startIdx !== endIdx && - temp !== i + startIdx - ) { - indices.push([temp, i + startIdx]) - temp = i + startIdx + 1 - } - } - temp !== endIdx && indices.push([temp, endIdx]) - return indices.filter(Boolean) -} - -const buildLinearOrder = (node) => { - const fontTree = [] - const newLinesPos = getDelimiterPos(node.characters, "\n") - newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => { - const newLinesRangeFont = node.getRangeFontName( - newLinesRangeStart, - newLinesRangeEnd, - ) - if (newLinesRangeFont === figma.mixed) { - const spacesPos = getDelimiterPos( - node.characters, - " ", - newLinesRangeStart, - newLinesRangeEnd, - ) - spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => { - const spacesRangeFont = node.getRangeFontName( - spacesRangeStart, - spacesRangeEnd, - ) - if (spacesRangeFont === figma.mixed) { - const spacesRangeFont = node.getRangeFontName( - spacesRangeStart, - spacesRangeStart[0], - ) - fontTree.push({ - start: spacesRangeStart, - delimiter: " ", - family: spacesRangeFont.family, - style: spacesRangeFont.style, - }) - } else { - fontTree.push({ - start: spacesRangeStart, - delimiter: " ", - family: spacesRangeFont.family, - style: spacesRangeFont.style, - }) - } - }) - } else { - fontTree.push({ - start: newLinesRangeStart, - delimiter: "\n", - family: newLinesRangeFont.family, - style: newLinesRangeFont.style, - }) - } - }) - return fontTree - .sort((a, b) => +a.start - +b.start) - .map(({ family, style, delimiter }) => ({ family, style, delimiter })) -} - -const setCharactersWithSmartMatchFont = async ( - node, - characters, - fallbackFont, -) => { - const rangeTree = buildLinearOrder(node) - const fontsToLoad = uniqBy( - rangeTree, - ({ family, style }) => `${family}::${style}`, - ).map(({ family, style }) => ({ - family, - style, - })) - - await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync)) - - node.fontName = fallbackFont - node.characters = characters - - let prevPos = 0 - rangeTree.forEach(({ family, style, delimiter }) => { - if (prevPos < node.characters.length) { - const delimeterPos = node.characters.indexOf(delimiter, prevPos) - const endPos = - delimeterPos > prevPos ? delimeterPos : node.characters.length - const matchedFont = { - family, - style, - } - node.setRangeFontName(prevPos, endPos, matchedFont) - prevPos = endPos + 1 - } - }) - return true -} - -// Add the cloneNode function implementation -async function cloneNode(params) { - const { nodeId, x, y } = params || {} - - if (!nodeId) { - throw new Error("Missing nodeId parameter") - } - - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - // Clone the node - const clone = node.clone() - - // If x and y are provided, move the clone to that position - if (x !== undefined && y !== undefined) { - if (!("x" in clone) || !("y" in clone)) { - throw new Error(`Cloned node does not support position: ${nodeId}`) - } - clone.x = x - clone.y = y - } - - // Add the clone to the same parent as the original node - if (node.parent) { - node.parent.appendChild(clone) - } else { - figma.currentPage.appendChild(clone) - } - - return { - id: clone.id, - name: clone.name, - x: "x" in clone ? clone.x : undefined, - y: "y" in clone ? clone.y : undefined, - width: "width" in clone ? clone.width : undefined, - height: "height" in clone ? clone.height : undefined, - } -} diff --git a/figma/mcp/src/cursor_mcp_plugin/manifest.json b/figma/mcp/src/cursor_mcp_plugin/manifest.json deleted file mode 100644 index cf37f29b..00000000 --- a/figma/mcp/src/cursor_mcp_plugin/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "Cursor MCP Plugin", - "id": "cursor-mcp-plugin", - "api": "1.0.0", - "main": "code.js", - "ui": "ui.html", - "editorType": ["figma"], - "permissions": [], - "networkAccess": { - "allowedDomains": ["https://google.com"], - "devAllowedDomains": ["http://localhost:3055", "ws://localhost:3055"] - }, - "documentAccess": "dynamic-page", - "enableProposedApi": true, - "enablePrivatePluginApi": true -} diff --git a/figma/mcp/src/cursor_mcp_plugin/setcharacters.js b/figma/mcp/src/cursor_mcp_plugin/setcharacters.js deleted file mode 100644 index dedf622b..00000000 --- a/figma/mcp/src/cursor_mcp_plugin/setcharacters.js +++ /dev/null @@ -1,215 +0,0 @@ -function uniqBy(arr, predicate) { - const cb = typeof predicate === "function" ? predicate : (o) => o[predicate]; - return [ - ...arr - .reduce((map, item) => { - const key = item === null || item === undefined ? item : cb(item); - - map.has(key) || map.set(key, item); - - return map; - }, new Map()) - .values(), - ]; -} -export const setCharacters = async (node, characters, options) => { - const fallbackFont = options?.fallbackFont || { - family: "Roboto", - style: "Regular", - }; - try { - if (node.fontName === figma.mixed) { - if (options?.smartStrategy === "prevail") { - const fontHashTree = {}; - for (let i = 1; i < node.characters.length; i++) { - const charFont = node.getRangeFontName(i - 1, i); - const key = `${charFont.family}::${charFont.style}`; - fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1; - } - const prevailedTreeItem = Object.entries(fontHashTree).sort( - (a, b) => b[1] - a[1] - )[0]; - const [family, style] = prevailedTreeItem[0].split("::"); - const prevailedFont = { - family, - style, - }; - await figma.loadFontAsync(prevailedFont); - node.fontName = prevailedFont; - } else if (options?.smartStrategy === "strict") { - return setCharactersWithStrictMatchFont(node, characters, fallbackFont); - } else if (options?.smartStrategy === "experimental") { - return setCharactersWithSmartMatchFont(node, characters, fallbackFont); - } else { - const firstCharFont = node.getRangeFontName(0, 1); - await figma.loadFontAsync(firstCharFont); - node.fontName = firstCharFont; - } - } else { - await figma.loadFontAsync({ - family: node.fontName.family, - style: node.fontName.style, - }); - } - } catch (err) { - console.warn( - `Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`, - err - ); - await figma.loadFontAsync(fallbackFont); - node.fontName = fallbackFont; - } - try { - node.characters = characters; - return true; - } catch (err) { - console.warn(`Failed to set characters. Skipped.`, err); - return false; - } -}; - -const setCharactersWithStrictMatchFont = async ( - node, - characters, - fallbackFont -) => { - const fontHashTree = {}; - for (let i = 1; i < node.characters.length; i++) { - const startIdx = i - 1; - const startCharFont = node.getRangeFontName(startIdx, i); - const startCharFontVal = `${startCharFont.family}::${startCharFont.style}`; - while (i < node.characters.length) { - i++; - const charFont = node.getRangeFontName(i - 1, i); - if (startCharFontVal !== `${charFont.family}::${charFont.style}`) { - break; - } - } - fontHashTree[`${startIdx}_${i}`] = startCharFontVal; - } - await figma.loadFontAsync(fallbackFont); - node.fontName = fallbackFont; - node.characters = characters; - console.log(fontHashTree); - await Promise.all( - Object.keys(fontHashTree).map(async (range) => { - console.log(range, fontHashTree[range]); - const [start, end] = range.split("_"); - const [family, style] = fontHashTree[range].split("::"); - const matchedFont = { - family, - style, - }; - await figma.loadFontAsync(matchedFont); - return node.setRangeFontName(Number(start), Number(end), matchedFont); - }) - ); - return true; -}; - -const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => { - const indices = []; - let temp = startIdx; - for (let i = startIdx; i < endIdx; i++) { - if ( - str[i] === delimiter && - i + startIdx !== endIdx && - temp !== i + startIdx - ) { - indices.push([temp, i + startIdx]); - temp = i + startIdx + 1; - } - } - temp !== endIdx && indices.push([temp, endIdx]); - return indices.filter(Boolean); -}; - -const buildLinearOrder = (node) => { - const fontTree = []; - const newLinesPos = getDelimiterPos(node.characters, "\n"); - newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => { - const newLinesRangeFont = node.getRangeFontName( - newLinesRangeStart, - newLinesRangeEnd - ); - if (newLinesRangeFont === figma.mixed) { - const spacesPos = getDelimiterPos( - node.characters, - " ", - newLinesRangeStart, - newLinesRangeEnd - ); - spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => { - const spacesRangeFont = node.getRangeFontName( - spacesRangeStart, - spacesRangeEnd - ); - if (spacesRangeFont === figma.mixed) { - const spacesRangeFont = node.getRangeFontName( - spacesRangeStart, - spacesRangeStart[0] - ); - fontTree.push({ - start: spacesRangeStart, - delimiter: " ", - family: spacesRangeFont.family, - style: spacesRangeFont.style, - }); - } else { - fontTree.push({ - start: spacesRangeStart, - delimiter: " ", - family: spacesRangeFont.family, - style: spacesRangeFont.style, - }); - } - }); - } else { - fontTree.push({ - start: newLinesRangeStart, - delimiter: "\n", - family: newLinesRangeFont.family, - style: newLinesRangeFont.style, - }); - } - }); - return fontTree - .sort((a, b) => +a.start - +b.start) - .map(({ family, style, delimiter }) => ({ family, style, delimiter })); -}; - -const setCharactersWithSmartMatchFont = async ( - node, - characters, - fallbackFont -) => { - const rangeTree = buildLinearOrder(node); - const fontsToLoad = uniqBy( - rangeTree, - ({ family, style }) => `${family}::${style}` - ).map(({ family, style }) => ({ - family, - style, - })); - - await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync)); - - node.fontName = fallbackFont; - node.characters = characters; - - let prevPos = 0; - rangeTree.forEach(({ family, style, delimiter }) => { - if (prevPos < node.characters.length) { - const delimeterPos = node.characters.indexOf(delimiter, prevPos); - const endPos = - delimeterPos > prevPos ? delimeterPos : node.characters.length; - const matchedFont = { - family, - style, - }; - node.setRangeFontName(prevPos, endPos, matchedFont); - prevPos = endPos + 1; - } - }); - return true; -}; diff --git a/figma/mcp/src/cursor_mcp_plugin/ui.html b/figma/mcp/src/cursor_mcp_plugin/ui.html deleted file mode 100644 index b0118de0..00000000 --- a/figma/mcp/src/cursor_mcp_plugin/ui.html +++ /dev/null @@ -1,553 +0,0 @@ - - - - - Cursor MCP Plugin - - - -
-
- -
-

Cursor Talk To Figma Plugin

-

Connect Figma to Cursor AI using MCP

-
-
- -
-
Connection
-
About
-
- -
-
- -
- - -
-
- -
- Not connected to Cursor MCP server -
- -
- -
-
- -
-
-

About Cursor Talk To Figma Plugin

-

- This plugin allows Cursor AI to communicate with Figma, enabling - AI-assisted design operations. created by - Sonny -

-

Version: 1.0.0

- -

How to Use

-
    -
  1. Make sure the MCP server is running in Cursor
  2. -
  3. Connect to the server using the port number (default: 3055)
  4. -
  5. Once connected, you can interact with Figma through Cursor
  6. -
-
-
-
- - - - diff --git a/figma/mcp/src/socket.ts b/figma/mcp/src/socket.ts deleted file mode 100644 index 63e90923..00000000 --- a/figma/mcp/src/socket.ts +++ /dev/null @@ -1,185 +0,0 @@ -import WebSocket, { WebSocketServer } from "ws" -import http from "http" - -// Store clients by channel -const channels = new Map>() - -function handleConnection(ws: WebSocket) { - // Don't add to clients immediately - wait for channel join - console.log("New client connected") - - // Send welcome message to the new client - ws.send( - JSON.stringify({ - type: "system", - message: "Please join a channel to start chatting", - }), - ) - - ws.on("close", () => { - console.log("Client disconnected") - - // Remove client from their channel - channels.forEach((clients, channelName) => { - if (clients.has(ws)) { - clients.delete(ws) - - // Notify other clients in same channel - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "system", - message: "A user has left the channel", - channel: channelName, - }), - ) - } - }) - } - }) - }) - - ws.on("message", (message) => { - try { - console.log("Received message from client:", message.toString()) - const data = JSON.parse(message.toString()) - - if (data.type === "join") { - const channelName = data.channel - if (!channelName || typeof channelName !== "string") { - ws.send( - JSON.stringify({ - type: "error", - message: "Channel name is required", - }), - ) - return - } - - // Create channel if it doesn't exist - if (!channels.has(channelName)) { - channels.set(channelName, new Set()) - } - - // Add client to channel - const channelClients = channels.get(channelName)! - channelClients.add(ws) - - // Notify client they joined successfully - ws.send( - JSON.stringify({ - type: "system", - message: `Joined channel: ${channelName}`, - channel: channelName, - }), - ) - - console.log("Sending message to client:", data.id) - - ws.send( - JSON.stringify({ - type: "system", - message: { - id: data.id, - result: "Connected to channel: " + channelName, - }, - channel: channelName, - }), - ) - - // Notify other clients in channel - channelClients.forEach((client) => { - if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "system", - message: "A new user has joined the channel", - channel: channelName, - }), - ) - } - }) - return - } - - // Handle regular messages - if (data.type === "message") { - const channelName = data.channel - if (!channelName || typeof channelName !== "string") { - ws.send( - JSON.stringify({ - type: "error", - message: "Channel name is required", - }), - ) - return - } - - const channelClients = channels.get(channelName) - if (!channelClients || !channelClients.has(ws)) { - ws.send( - JSON.stringify({ - type: "error", - message: "You must join the channel first", - }), - ) - return - } - - // Broadcast to all clients in the channel - channelClients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - console.log("Broadcasting message to client:", data.message) - client.send( - JSON.stringify({ - type: "broadcast", - message: data.message, - sender: client === ws ? "You" : "User", - channel: channelName, - }), - ) - } - }) - } - } catch (err) { - console.error("Error handling message:", err) - } - }) -} - -// Create HTTP server -const server = http.createServer((req, res) => { - // Handle CORS preflight - if (req.method === "OPTIONS") { - res.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - }) - res.end() - return - } - - // Return response for HTTP requests - res.writeHead(200, { - "Content-Type": "text/plain", - "Access-Control-Allow-Origin": "*", - }) - res.end("WebSocket server running") -}) - -// Create WebSocket server -const wss = new WebSocketServer({ server }) - -wss.on("connection", handleConnection) - -// Start server -const PORT = 3055 -server.listen(PORT, () => { - console.log(`WebSocket server running on port ${PORT}`) - // Uncomment this to log when running in WSL for debugging - // console.log('To connect from outside WSL, find your IP with "ip addr show"'); -}) - -console.log(`Socket server started on port ${PORT}`) diff --git a/figma/mcp/src/talk_to_figma_mcp/server.ts b/figma/mcp/src/talk_to_figma_mcp/server.ts deleted file mode 100644 index c0de0e24..00000000 --- a/figma/mcp/src/talk_to_figma_mcp/server.ts +++ /dev/null @@ -1,1253 +0,0 @@ -#!/usr/bin/env node - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" -import { z } from "zod" -import WebSocket from "ws" -import { v4 as uuidv4 } from "uuid" - -// Define TypeScript interfaces for Figma responses -interface FigmaResponse { - id: string - result?: any - error?: string -} - -// Custom logging functions that write to stderr instead of stdout to avoid being captured -const logger = { - info: (message: string) => process.stderr.write(`[INFO] ${message}\n`), - debug: (message: string) => process.stderr.write(`[DEBUG] ${message}\n`), - warn: (message: string) => process.stderr.write(`[WARN] ${message}\n`), - error: (message: string) => process.stderr.write(`[ERROR] ${message}\n`), - log: (message: string) => process.stderr.write(`[LOG] ${message}\n`), -} - -// WebSocket connection and request tracking -let ws: WebSocket | null = null -const pendingRequests = new Map< - string, - { - resolve: (value: unknown) => void - reject: (reason: unknown) => void - timeout: ReturnType - } ->() - -// Track which channel each client is in -let currentChannel: string | null = null - -// Create MCP server -const server = new McpServer({ - name: "TalkToFigmaMCP", - version: "1.0.0", -}) -// Add command line argument parsing -const args = process.argv.slice(2) -const serverArg = args.find((arg) => arg.startsWith("--server=")) -const serverUrl = serverArg ? serverArg.split("=")[1] : "localhost" -const WS_URL = - serverUrl === "localhost" ? `ws://${serverUrl}` : `wss://${serverUrl}` - -// Document Info Tool -server.tool( - "get_document_info", - "Get detailed information about the current Figma document", - {}, - async () => { - try { - const result = await sendCommandToFigma("get_document_info") - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting document info: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Selection Tool -server.tool( - "get_selection", - "Get information about the current selection in Figma", - {}, - async () => { - try { - const result = await sendCommandToFigma("get_selection") - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting selection: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -//get component set styles -server.tool( - "get_component_set_styles", - "Get the styles of a component set", - { - nodeId: z.string().describe("The ID of the node to get information about"), - }, - async ({ nodeId }) => { - try { - const result = await sendCommandToFigma("get_component_set_styles", { - nodeId, - }) - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting node info: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Node Info Tool -server.tool( - "get_node_info", - "Get detailed information about a specific node in Figma", - { - nodeId: z.string().describe("The ID of the node to get information about"), - }, - async ({ nodeId }) => { - try { - const result = await sendCommandToFigma("get_node_info", { nodeId }) - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting node info: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Nodes Info Tool -server.tool( - "get_nodes_info", - "Get detailed information about multiple nodes in Figma", - { - nodeIds: z - .array(z.string()) - .describe("Array of node IDs to get information about"), - }, - async ({ nodeIds }) => { - try { - const results = await Promise.all( - nodeIds.map(async (nodeId) => { - const result = await sendCommandToFigma("get_node_info", { nodeId }) - return { nodeId, info: result } - }), - ) - return { - content: [ - { - type: "text", - text: JSON.stringify(results), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting nodes info: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Create Rectangle Tool -server.tool( - "create_rectangle", - "Create a new rectangle in Figma", - { - x: z.number().describe("X position"), - y: z.number().describe("Y position"), - width: z.number().describe("Width of the rectangle"), - height: z.number().describe("Height of the rectangle"), - name: z.string().optional().describe("Optional name for the rectangle"), - parentId: z - .string() - .optional() - .describe("Optional parent node ID to append the rectangle to"), - }, - async ({ x, y, width, height, name, parentId }) => { - try { - const result = await sendCommandToFigma("create_rectangle", { - x, - y, - width, - height, - name: name || "Rectangle", - parentId, - }) - return { - content: [ - { - type: "text", - text: `Created rectangle "${JSON.stringify(result)}"`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating rectangle: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Create Frame Tool -server.tool( - "create_frame", - "Create a new frame in Figma", - { - x: z.number().describe("X position"), - y: z.number().describe("Y position"), - width: z.number().describe("Width of the frame"), - height: z.number().describe("Height of the frame"), - name: z.string().optional().describe("Optional name for the frame"), - parentId: z - .string() - .optional() - .describe("Optional parent node ID to append the frame to"), - fillColor: z - .object({ - r: z.number().min(0).max(1).describe("Red component (0-1)"), - g: z.number().min(0).max(1).describe("Green component (0-1)"), - b: z.number().min(0).max(1).describe("Blue component (0-1)"), - a: z - .number() - .min(0) - .max(1) - .optional() - .describe("Alpha component (0-1)"), - }) - .optional() - .describe("Fill color in RGBA format"), - strokeColor: z - .object({ - r: z.number().min(0).max(1).describe("Red component (0-1)"), - g: z.number().min(0).max(1).describe("Green component (0-1)"), - b: z.number().min(0).max(1).describe("Blue component (0-1)"), - a: z - .number() - .min(0) - .max(1) - .optional() - .describe("Alpha component (0-1)"), - }) - .optional() - .describe("Stroke color in RGBA format"), - strokeWeight: z.number().positive().optional().describe("Stroke weight"), - }, - async ({ - x, - y, - width, - height, - name, - parentId, - fillColor, - strokeColor, - strokeWeight, - }) => { - try { - const result = await sendCommandToFigma("create_frame", { - x, - y, - width, - height, - name: name || "Frame", - parentId, - fillColor: fillColor || { r: 1, g: 1, b: 1, a: 1 }, - strokeColor: strokeColor, - strokeWeight: strokeWeight, - }) - const typedResult = result as { name: string; id: string } - return { - content: [ - { - type: "text", - text: `Created frame "${typedResult.name}" with ID: ${typedResult.id}. Use the ID as the parentId to appendChild inside this frame.`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating frame: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Create Text Tool -server.tool( - "create_text", - "Create a new text element in Figma", - { - x: z.number().describe("X position"), - y: z.number().describe("Y position"), - text: z.string().describe("Text content"), - fontSize: z.number().optional().describe("Font size (default: 14)"), - fontWeight: z - .number() - .optional() - .describe("Font weight (e.g., 400 for Regular, 700 for Bold)"), - fontColor: z - .object({ - r: z.number().min(0).max(1).describe("Red component (0-1)"), - g: z.number().min(0).max(1).describe("Green component (0-1)"), - b: z.number().min(0).max(1).describe("Blue component (0-1)"), - a: z - .number() - .min(0) - .max(1) - .optional() - .describe("Alpha component (0-1)"), - }) - .optional() - .describe("Font color in RGBA format"), - name: z - .string() - .optional() - .describe("Optional name for the text node by default following text"), - parentId: z - .string() - .optional() - .describe("Optional parent node ID to append the text to"), - }, - async ({ x, y, text, fontSize, fontWeight, fontColor, name, parentId }) => { - try { - const result = await sendCommandToFigma("create_text", { - x, - y, - text, - fontSize: fontSize || 14, - fontWeight: fontWeight || 400, - fontColor: fontColor || { r: 0, g: 0, b: 0, a: 1 }, - name: name || "Text", - parentId, - }) - const typedResult = result as { name: string; id: string } - return { - content: [ - { - type: "text", - text: `Created text "${typedResult.name}" with ID: ${typedResult.id}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating text: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Set Fill Color Tool -server.tool( - "set_fill_color", - "Set the fill color of a node in Figma can be TextNode or FrameNode", - { - nodeId: z.string().describe("The ID of the node to modify"), - r: z.number().min(0).max(1).describe("Red component (0-1)"), - g: z.number().min(0).max(1).describe("Green component (0-1)"), - b: z.number().min(0).max(1).describe("Blue component (0-1)"), - a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)"), - }, - async ({ nodeId, r, g, b, a }) => { - try { - const result = await sendCommandToFigma("set_fill_color", { - nodeId, - color: { r, g, b, a: a || 1 }, - }) - const typedResult = result as { name: string } - return { - content: [ - { - type: "text", - text: `Set fill color of node "${typedResult.name}" to RGBA(${r}, ${g}, ${b}, ${a || 1})`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting fill color: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Set Stroke Color Tool -server.tool( - "set_stroke_color", - "Set the stroke color of a node in Figma", - { - nodeId: z.string().describe("The ID of the node to modify"), - r: z.number().min(0).max(1).describe("Red component (0-1)"), - g: z.number().min(0).max(1).describe("Green component (0-1)"), - b: z.number().min(0).max(1).describe("Blue component (0-1)"), - a: z.number().min(0).max(1).optional().describe("Alpha component (0-1)"), - weight: z.number().positive().optional().describe("Stroke weight"), - }, - async ({ nodeId, r, g, b, a, weight }) => { - try { - const result = await sendCommandToFigma("set_stroke_color", { - nodeId, - color: { r, g, b, a: a || 1 }, - weight: weight || 1, - }) - const typedResult = result as { name: string } - return { - content: [ - { - type: "text", - text: `Set stroke color of node "${typedResult.name}" to RGBA(${r}, ${g}, ${b}, ${a || 1}) with weight ${weight || 1}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting stroke color: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Move Node Tool -server.tool( - "move_node", - "Move a node to a new position in Figma", - { - nodeId: z.string().describe("The ID of the node to move"), - x: z.number().describe("New X position"), - y: z.number().describe("New Y position"), - }, - async ({ nodeId, x, y }) => { - try { - const result = await sendCommandToFigma("move_node", { nodeId, x, y }) - const typedResult = result as { name: string } - return { - content: [ - { - type: "text", - text: `Moved node "${typedResult.name}" to position (${x}, ${y})`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error moving node: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Clone Node Tool -server.tool( - "clone_node", - "Clone an existing node in Figma", - { - nodeId: z.string().describe("The ID of the node to clone"), - x: z.number().optional().describe("New X position for the clone"), - y: z.number().optional().describe("New Y position for the clone"), - }, - async ({ nodeId, x, y }) => { - try { - const result = await sendCommandToFigma("clone_node", { nodeId, x, y }) - const typedResult = result as { name: string; id: string } - return { - content: [ - { - type: "text", - text: `Cloned node "${typedResult.name}" with new ID: ${typedResult.id}${x !== undefined && y !== undefined ? ` at position (${x}, ${y})` : ""}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error cloning node: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Resize Node Tool -server.tool( - "resize_node", - "Resize a node in Figma", - { - nodeId: z.string().describe("The ID of the node to resize"), - width: z.number().positive().describe("New width"), - height: z.number().positive().describe("New height"), - }, - async ({ nodeId, width, height }) => { - try { - const result = await sendCommandToFigma("resize_node", { - nodeId, - width, - height, - }) - const typedResult = result as { name: string } - return { - content: [ - { - type: "text", - text: `Resized node "${typedResult.name}" to width ${width} and height ${height}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error resizing node: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Delete Node Tool -server.tool( - "delete_node", - "Delete a node from Figma", - { - nodeId: z.string().describe("The ID of the node to delete"), - }, - async ({ nodeId }) => { - try { - await sendCommandToFigma("delete_node", { nodeId }) - return { - content: [ - { - type: "text", - text: `Deleted node with ID: ${nodeId}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error deleting node: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Get Styles Tool -server.tool( - "get_styles", - "Get all styles from the current Figma document", - {}, - async () => { - try { - const result = await sendCommandToFigma("get_styles") - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting styles: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Get Local Components Tool -server.tool( - "get_local_components", - "Get all local components from the Figma document", - {}, - async () => { - try { - const result = await sendCommandToFigma("get_local_components") - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting local components: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -//Get Team Components Tool -server.tool( - "get_team_components", - "Get all team library components available in Figma", - {}, - async () => { - try { - const result = await sendCommandToFigma("get_team_components") - return { - content: [ - { - type: "text", - text: JSON.stringify(result, null, 2), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting team components: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Create Component Instance Tool -server.tool( - "create_component_instance", - "Create an instance of a component in Figma", - { - componentKey: z.string().describe("Key of the component to instantiate"), - x: z.number().describe("X position"), - y: z.number().describe("Y position"), - }, - async ({ componentKey, x, y }) => { - try { - const result = await sendCommandToFigma("create_component_instance", { - componentKey, - x, - y, - }) - const typedResult = result as { name: string; id: string } - return { - content: [ - { - type: "text", - text: `Created component instance "${typedResult.name}" with ID: ${typedResult.id}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating component instance: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Export Node as Image Tool -server.tool( - "export_node_as_image", - "Export a node as an image from Figma", - { - nodeId: z.string().describe("The ID of the node to export"), - format: z - .enum(["PNG", "JPG", "SVG", "PDF"]) - .optional() - .describe("Export format"), - scale: z.number().positive().optional().describe("Export scale"), - }, - async ({ nodeId, format, scale }) => { - try { - const result = await sendCommandToFigma("export_node_as_image", { - nodeId, - format: format || "PNG", - scale: scale || 1, - }) - const typedResult = result as any - - // return { - // content: [ - // { - // type: "image", - // data: typedResult.imageData, - // mimeType: typedResult.mimeType || "image/png" - // } - // ] - // }; - return { - content: [ - { - type: "text", - text: JSON.stringify(typedResult), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error exporting node as image: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -//Execute Figma Code Tool -server.tool( - "execute_figma_code", - "Execute arbitrary JavaScript code in Figma (use with caution)", - { - code: z.string().describe("JavaScript code to execute in Figma"), - }, - async ({ code }) => { - try { - const result = await sendCommandToFigma("execute_code", { code }) - return { - content: [ - { - type: "text", - text: `Code executed successfully: ${JSON.stringify(result, null, 2)}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error executing code: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Set Corner Radius Tool -server.tool( - "set_corner_radius", - "Set the corner radius of a node in Figma", - { - nodeId: z.string().describe("The ID of the node to modify"), - radius: z.number().min(0).describe("Corner radius value"), - corners: z - .array(z.boolean()) - .length(4) - .optional() - .describe( - "Optional array of 4 booleans to specify which corners to round [topLeft, topRight, bottomRight, bottomLeft]", - ), - }, - async ({ nodeId, radius, corners }) => { - try { - const result = await sendCommandToFigma("set_corner_radius", { - nodeId, - radius, - corners: corners || [true, true, true, true], - }) - const typedResult = result as { name: string } - return { - content: [ - { - type: "text", - text: `Set corner radius of node "${typedResult.name}" to ${radius}px`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting corner radius: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Set Text Content Tool -server.tool( - "set_text_content", - "Set the text content of an existing text node in Figma", - { - nodeId: z.string().describe("The ID of the text node to modify"), - text: z.string().describe("New text content"), - }, - async ({ nodeId, text }) => { - try { - const result = await sendCommandToFigma("set_text_content", { - nodeId, - text, - }) - const typedResult = result as { name: string } - return { - content: [ - { - type: "text", - text: `Updated text content of node "${typedResult.name}" to "${text}"`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error setting text content: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Define design strategy prompt -server.prompt( - "design_strategy", - "Best practices for working with Figma designs", - (extra) => { - return { - messages: [ - { - role: "assistant", - content: { - type: "text", - text: `When working with Figma designs, follow these best practices: - -1. Start with Document Structure: - - First use get_document_info() to understand the current document - - Plan your layout hierarchy before creating elements - - Create a main container frame for each screen/section - -2. Naming Conventions: - - Use descriptive, semantic names for all elements - - Follow a consistent naming pattern (e.g., "Login Screen", "Logo Container", "Email Input") - - Group related elements with meaningful names - -3. Layout Hierarchy: - - Create parent frames first, then add child elements - - For forms/login screens: - * Start with the main screen container frame - * Create a logo container at the top - * Group input fields in their own containers - * Place action buttons (login, submit) after inputs - * Add secondary elements (forgot password, signup links) last - -4. Input Fields Structure: - - Create a container frame for each input field - - Include a label text above or inside the input - - Group related inputs (e.g., username/password) together - -5. Element Creation: - - Use create_frame() for containers and input fields - - Use create_text() for labels, buttons text, and links - - Set appropriate colors and styles: - * Use fillColor for backgrounds - * Use strokeColor for borders - * Set proper fontWeight for different text elements - -6. Mofifying existing elements: - - use set_text_content() to modify text content. - -7. Visual Hierarchy: - - Position elements in logical reading order (top to bottom) - - Maintain consistent spacing between elements - - Use appropriate font sizes for different text types: - * Larger for headings/welcome text - * Medium for input labels - * Standard for button text - * Smaller for helper text/links - -8. Best Practices: - - Verify each creation with get_node_info() - - Use parentId to maintain proper hierarchy - - Group related elements together in frames - - Keep consistent spacing and alignment - -Example Login Screen Structure: -- Login Screen (main frame) - - Logo Container (frame) - - Logo (image/text) - - Welcome Text (text) - - Input Container (frame) - - Email Input (frame) - - Email Label (text) - - Email Field (frame) - - Password Input (frame) - - Password Label (text) - - Password Field (frame) - - Login Button (frame) - - Button Text (text) - - Helper Links (frame) - - Forgot Password (text) - - Don't have account (text)`, - }, - }, - ], - description: "Best practices for working with Figma designs", - } - }, -) - -// Define command types and parameters -type FigmaCommand = - | "get_document_info" - | "get_selection" - | "get_node_info" - | "create_rectangle" - | "create_frame" - | "create_text" - | "set_fill_color" - | "set_stroke_color" - | "move_node" - | "resize_node" - | "delete_node" - | "get_styles" - | "get_local_components" - | "get_team_components" - | "create_component_instance" - | "export_node_as_image" - | "execute_code" - | "join" - | "set_corner_radius" - | "set_text_content" - | "clone_node" - | "get_component_set_styles" - -// Update the connectToFigma function -function connectToFigma(port: number = 3055) { - // If already connected, do nothing - if (ws && ws.readyState === WebSocket.OPEN) { - logger.info("Already connected to Figma") - return - } - - const wsUrl = serverUrl === "localhost" ? `${WS_URL}:${port}` : WS_URL - logger.info(`Connecting to Figma socket server at ${wsUrl}...`) - ws = new WebSocket(wsUrl) - - ws.on("open", () => { - logger.info("Connected to Figma socket server") - // Reset channel on new connection - currentChannel = null - }) - - ws.on("message", (data: any) => { - try { - const json = JSON.parse(data) as { message: FigmaResponse } - const myResponse = json.message - logger.debug(`Received message: ${JSON.stringify(myResponse)}`) - logger.log("myResponse" + JSON.stringify(myResponse)) - - // Handle response to a request - if ( - myResponse.id && - pendingRequests.has(myResponse.id) && - myResponse.result - ) { - const request = pendingRequests.get(myResponse.id)! - clearTimeout(request.timeout) - - if (myResponse.error) { - logger.error(`Error from Figma: ${myResponse.error}`) - request.reject(new Error(myResponse.error)) - } else { - if (myResponse.result) { - request.resolve(myResponse.result) - } - } - - pendingRequests.delete(myResponse.id) - } else { - // Handle broadcast messages or events - logger.info(`Received broadcast message: ${JSON.stringify(myResponse)}`) - } - } catch (error) { - logger.error( - `Error parsing message: ${error instanceof Error ? error.message : String(error)}`, - ) - } - }) - - ws.on("error", (error) => { - logger.error(`Socket error: ${error}`) - }) - - ws.on("close", () => { - logger.info("Disconnected from Figma socket server") - ws = null - - // Reject all pending requests - for (const [id, request] of pendingRequests.entries()) { - clearTimeout(request.timeout) - request.reject(new Error("Connection closed")) - pendingRequests.delete(id) - } - - // Attempt to reconnect - logger.info("Attempting to reconnect in 2 seconds...") - setTimeout(() => connectToFigma(port), 2000) - }) -} - -// Function to join a channel -async function joinChannel(channelName: string): Promise { - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Not connected to Figma") - } - - try { - await sendCommandToFigma("join", { channel: channelName }) - currentChannel = channelName - logger.info(`Joined channel: ${channelName}`) - } catch (error) { - logger.error( - `Failed to join channel: ${error instanceof Error ? error.message : String(error)}`, - ) - throw error - } -} - -// Function to send commands to Figma -function sendCommandToFigma( - command: FigmaCommand, - params: unknown = {}, -): Promise { - return new Promise((resolve, reject) => { - // If not connected, try to connect first - if (!ws || ws.readyState !== WebSocket.OPEN) { - connectToFigma() - reject(new Error("Not connected to Figma. Attempting to connect...")) - return - } - - // Check if we need a channel for this command - const requiresChannel = command !== "join" - if (requiresChannel && !currentChannel) { - reject(new Error("Must join a channel before sending commands")) - return - } - - const id = uuidv4() - const request = { - id, - type: command === "join" ? "join" : "message", - ...(command === "join" - ? { channel: (params as any).channel } - : { channel: currentChannel }), - message: { - id, - command, - params: { - ...(params as any), - }, - }, - } - - // Set timeout for request - const timeout = setTimeout(() => { - if (pendingRequests.has(id)) { - pendingRequests.delete(id) - logger.error(`Request ${id} to Figma timed out after 30 seconds`) - reject(new Error("Request to Figma timed out")) - } - }, 30000) // 30 second timeout - - // Store the promise callbacks to resolve/reject later - pendingRequests.set(id, { resolve, reject, timeout }) - - // Send the request - logger.info(`Sending command to Figma: ${command}`) - logger.debug(`Request details: ${JSON.stringify(request)}`) - ws.send(JSON.stringify(request)) - }) -} - -// Update the join_channel tool -server.tool( - "join_channel", - "Join a specific channel to communicate with Figma", - { - channel: z.string().describe("The name of the channel to join").default(""), - }, - async ({ channel }) => { - try { - if (!channel) { - // If no channel provided, ask the user for input - return { - content: [ - { - type: "text", - text: "Please provide a channel name to join:", - }, - ], - followUp: { - tool: "join_channel", - description: "Join the specified channel", - }, - } - } - - await joinChannel(channel) - return { - content: [ - { - type: "text", - text: `Successfully joined channel: ${channel}`, - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error joining channel: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Start the server -async function main() { - try { - // Try to connect to Figma socket server - connectToFigma() - } catch (error) { - logger.warn( - `Could not connect to Figma initially: ${error instanceof Error ? error.message : String(error)}`, - ) - logger.warn("Will try to connect when the first command is sent") - } - - // Start the MCP server with stdio transport - const transport = new StdioServerTransport() - await server.connect(transport) - logger.info("FigmaMCP server running on stdio") -} - -// Run the server -main().catch((error) => { - logger.error( - `Error starting FigmaMCP server: ${error instanceof Error ? error.message : String(error)}`, - ) - process.exit(1) -}) diff --git a/figma/mcp/tsconfig.json b/figma/mcp/tsconfig.json deleted file mode 100644 index d6f494b2..00000000 --- a/figma/mcp/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022"], - "types": ["@figma/plugin-typings"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/figma/mcp/tsup.config.ts b/figma/mcp/tsup.config.ts deleted file mode 100644 index 42836abc..00000000 --- a/figma/mcp/tsup.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "tsup" - -export default defineConfig({ - entry: ["src/talk_to_figma_mcp/server.ts"], - format: ["cjs", "esm"], - dts: false, - clean: true, - outDir: "dist", - target: "node18", - sourcemap: true, - minify: false, - splitting: false, - bundle: true, -}) diff --git a/figma/plugin/eslint.config.mjs b/figma/plugin/eslint.config.mjs deleted file mode 100644 index b38426e0..00000000 --- a/figma/plugin/eslint.config.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { config } from "@jongh/eslint/react-internal" - -/** @type {import("eslint").Linter.Config} */ -export default [ - ...config, - { - rules: { - "@typescript-eslint/no-explicit-any": "off", - }, - }, -] diff --git a/figma/plugin/package.json b/figma/plugin/package.json deleted file mode 100644 index 35d85488..00000000 --- a/figma/plugin/package.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "@jongh/figma-plugin", - "private": true, - "type": "module", - "scripts": { - "dev": "plugma dev", - "socket": "tsx src/socket.ts", - "start": "concurrently \"pnpm run socket\" \"pnpm run dev\" --names \"SOCKET,MCP\" --prefix-colors \"blue,green\"", - "build:server": "tsup", - "format": "prettier --write" - }, - "bin": "dist/server/server.cjs", - "files": [ - "dist/server/server.cjs" - ], - "dependencies": { - "@modelcontextprotocol/sdk": "^1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "uuid": "^11", - "ws": "^8", - "xml-js": "^1.6.11", - "zod": "^4" - }, - "devDependencies": { - "@figma/plugin-typings": "^1.100.2", - "@jongh/eslint": "workspace:*", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@types/uuid": "^9.0.8", - "@types/ws": "^8.5.10", - "@vitejs/plugin-react": "^4.3.3", - "concurrently": "^8", - "plugma": "^1", - "tsup": "^8.5.0", - "tsx": "^4.7.0", - "vite": "^5.4.10", - "vite-plugin-generate-file": "^0.3.1" - }, - "plugma": { - "manifest": { - "id": "@jongh/figma-plugin/dev", - "name": "@jongh/figma-plugin/dev", - "main": "src/main.ts", - "ui": "src/ui.tsx", - "editorType": [ - "figma", - "figjam", - "dev" - ], - "capabilities": [ - "inspect" - ], - "networkAccess": { - "allowedDomains": [ - "none" - ], - "devAllowedDomains": [ - "http://localhost:*", - "ws://localhost:9001" - ] - } - } - } -} diff --git a/figma/plugin/src/App.css b/figma/plugin/src/App.css deleted file mode 100644 index 0b137be8..00000000 --- a/figma/plugin/src/App.css +++ /dev/null @@ -1,30 +0,0 @@ -.container { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; - flex-direction: column; -} - -.banner { - display: flex; - align-items: center; - gap: 18px; - margin-bottom: 16px; -} - -.node-count { - font-size: 11px; -} - -.field { - display: flex; - gap: var(--spacer-2); - height: var(--spacer-5); - align-items: center; -} - -.create-rectangles .Input { - width: 40px; -} diff --git a/figma/plugin/src/app.tsx b/figma/plugin/src/app.tsx deleted file mode 100644 index 2714aa61..00000000 --- a/figma/plugin/src/app.tsx +++ /dev/null @@ -1,303 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { useEffect, useState } from "react" - -interface ConnectionState { - connected: boolean - port: number - socket: WebSocket | null -} - -const App = () => { - const [connectionState, setConnectionState] = useState({ - connected: false, - port: 3055, - socket: null, - }) - const [selectionData, setSelectionData] = useState(null) - const [status, setStatus] = useState("Disconnected") - - // 플러그인으로부터 메시지 받기 - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - const message = event.data.pluginMessage - if (!message) return - - switch (message.type) { - case "SELECTION_DATA": - setSelectionData(message.data) - break - case "connection-status": - setStatus(message.connected ? "Connected" : message.message) - break - case "command-result": - // MCP 명령 결과를 WebSocket으로 전송 - sendToWebSocket({ - id: message.id, - result: message.result, - }) - break - case "command-error": - // MCP 명령 에러를 WebSocket으로 전송 - sendToWebSocket({ - id: message.id, - error: message.error, - }) - break - } - } - - window.addEventListener("message", handleMessage) - return () => window.removeEventListener("message", handleMessage) - }, [connectionState.socket]) - - // WebSocket 연결 - const connectToServer = async () => { - try { - if (connectionState.connected && connectionState.socket) { - setStatus("Already connected") - return - } - - const ws = new WebSocket(`ws://localhost:${connectionState.port}`) - - ws.onopen = () => { - console.log("Connected to WebSocket server") - setConnectionState((prev) => ({ - ...prev, - socket: ws, - connected: true, - })) - setStatus("Connected to server") - } - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data) - console.log("Received WebSocket message:", data) - - if (data.type === "broadcast" && data.message?.command) { - // MCP 서버로부터 명령 받음 - parent.postMessage( - { - pluginMessage: { - type: "execute-command", - id: data.message.id, - command: data.message.command, - params: data.message.params, - }, - }, - "*", - ) - } - } catch (error) { - console.error("Error parsing WebSocket message:", error) - } - } - - ws.onclose = () => { - setConnectionState((prev) => ({ - ...prev, - connected: false, - socket: null, - })) - setStatus("Disconnected") - } - - ws.onerror = (error) => { - console.error("WebSocket error:", error) - setStatus("Connection error") - } - } catch (error) { - console.error("Connection error:", error) - setStatus(`Connection error: ${error}`) - } - } - - // WebSocket 연결 해제 - const disconnect = () => { - if (connectionState.socket) { - connectionState.socket.close() - } - } - - // WebSocket으로 메시지 전송 - const sendToWebSocket = (message: any) => { - if (connectionState.socket) { - connectionState.socket.send( - JSON.stringify({ - type: "message", - message, - }), - ) - } - } - - return ( -
-

- Figma MCP Plugin -

- -
- -
- - setConnectionState((prev) => ({ - ...prev, - port: parseInt(e.target.value) || 3055, - })) - } - style={{ - margin: "1px 0", - display: "flex", - backgroundColor: "var(--figma-color-bg-secondary)", - border: "1px solid transparent", - height: "var(--spacer-4)", - borderRadius: "var(--radius-medium)", - alignItems: "center", - flex: 1, - padding: "0 7px", - borderLeft: 0, - borderRight: 0, - backgroundClip: "padding-box", - marginLeft: 0, - width: "100%", - fontSize: "12px", - }} - disabled={connectionState.connected} - /> - -
-
- -
- Status: {status} -
- - {selectionData && ( -
-

- XML Output: -

-
-            {selectionData.xml?.join("\n\n")}
-          
- -

- React Nodes: -

-
-            {JSON.stringify(selectionData.reactNodes, null, 2)}
-          
- -

- Variables: -

-
-            {JSON.stringify(selectionData.variables, null, 2)}
-          
-
- )} -
- ) -} - -export default App diff --git a/figma/plugin/src/main.ts b/figma/plugin/src/main.ts deleted file mode 100644 index 338b66e0..00000000 --- a/figma/plugin/src/main.ts +++ /dev/null @@ -1,412 +0,0 @@ -// Read the docs https://plugma.dev/docs -import { - frameNodeToReactNode, - groupNodeToReactNode, - instanceNodeToReactNode, - rectangleNodeToReactNode, - textNodeToReactNode, -} from "./utils/node" - -// WebSocket 상태 관리 -const state = { - serverPort: 3055, - connected: false, - socket: null, - pendingRequests: new Map(), - channel: null as string | null, -} - -export default function () { - figma.showUI(__html__, { width: 300, height: 260, themeColors: true }) - - // UI로부터 메시지 처리 - figma.ui.onmessage = async (msg) => { - switch (msg.type) { - case "connect-websocket": - await connectToServer(msg.port || 3055) - break - case "disconnect-websocket": - disconnectFromServer() - break - case "execute-command": - // MCP 서버로부터의 명령 실행 - try { - const result = await handleCommand(msg.command, msg.params) - figma.ui.postMessage({ - type: "command-result", - id: msg.id, - result, - }) - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error" - figma.ui.postMessage({ - type: "command-error", - id: msg.id, - error: errorMessage || null, - }) - } - break - } - } - - async function handleSelectionChange() { - try { - const selection = figma.currentPage.selection - - let reactNodes: any[] = [] - const allUsedVariables = new Map() - - if (selection.length > 0) { - reactNodes = await Promise.all( - selection.map((node) => figmaNodeToReactNode(node)), - ) - - for (const node of selection) { - await collectUsedVariables(node, allUsedVariables) - } - } - - const variablesData = Object.fromEntries(allUsedVariables) - - // React Node를 XML로 변환하는 함수 - const reactNodeToXML = (node: any, indent = 0): string => { - const { type, props, children } = node - const spaces = " ".repeat(indent) - let xml = `${spaces}<${type}` - - // props를 attributes로 변환 - if (props) { - Object.entries(props).forEach(([key, value]) => { - if (value !== undefined && value !== null && key !== "children") { - let strValue = "" - if (typeof value === "string") { - strValue = value - } else if (typeof value === "object") { - strValue = JSON.stringify(value) - } else { - strValue = String(value) - } - xml += `\n${spaces} ${key}="${strValue}"` - } - }) - } - - if (!children || (Array.isArray(children) && children.length === 0)) { - xml += " />" - } else { - xml += ">" - - if (typeof children === "string") { - xml += `\n${spaces} ${children}\n${spaces}` - } else if (Array.isArray(children)) { - xml += "\n" - children.forEach((child) => { - xml += reactNodeToXML(child, indent + 1) + "\n" - }) - xml += spaces - } - - xml += `` - } - - return xml - } - - const xmlData = reactNodes.map((node) => reactNodeToXML(node)) - figma.ui.postMessage({ - type: "SELECTION_DATA", - data: { - reactNodes, - variables: variablesData, - xml: xmlData, - }, - }) - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error" - console.error("선택 읽기 오류:", errorMessage) - - figma.ui.postMessage({ - type: "ERROR", - message: errorMessage, - }) - } - } - - figma.on("selectionchange", handleSelectionChange) - - // WebSocket 서버 연결 - async function connectToServer(port: number) { - try { - if (state.connected && state.socket) { - figma.ui.postMessage({ - type: "connection-status", - connected: true, - message: "Already connected to server", - }) - return - } - - state.serverPort = port - figma.ui.postMessage({ - type: "connection-status", - connected: false, - message: "WebSocket connection must be handled by UI", - }) - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error" - figma.ui.postMessage({ - type: "connection-status", - connected: false, - message: `Connection error: ${errorMessage}`, - }) - } - } - - // WebSocket 연결 해제 - function disconnectFromServer() { - if (state.socket) { - state.socket = null - state.connected = false - figma.ui.postMessage({ - type: "connection-status", - connected: false, - message: "Disconnected from server", - }) - } - } - - async function handleCommand(command: string, params: any) { - switch (command) { - case "get_document_info": - return await getDocumentInfo() - case "get_selection": - return await getSelection() - case "get_node_info": - if (!params?.nodeId) throw new Error("Missing nodeId parameter") - return await getNodeInfo(params.nodeId) - default: - throw new Error(`Unknown command: ${command}`) - } - } - - // Figma API 래퍼 함수들 - async function getDocumentInfo() { - await figma.currentPage.loadAsync() - const page = figma.currentPage - return { - name: page.name, - id: page.id, - type: page.type, - children: page.children.map((node) => ({ - id: node.id, - name: node.name, - type: node.type, - })), - currentPage: { - id: page.id, - name: page.name, - childCount: page.children.length, - }, - } - } - - async function getSelection() { - const selection = figma.currentPage.selection - - let reactNodes: any[] = [] - const allUsedVariables = new Map() - - if (selection.length > 0) { - reactNodes = await Promise.all( - selection.map((node) => figmaNodeToReactNode(node)), - ) - - for (const node of selection) { - await collectUsedVariables(node, allUsedVariables) - } - } - - const variablesData = Object.fromEntries(allUsedVariables) - - // React Node를 XML로 변환하는 함수 - const reactNodeToXML = (node: any, indent = 0): string => { - const { type, props, children } = node - const spaces = " ".repeat(indent) - let xml = `${spaces}<${type}` - - // props를 attributes로 변환 - if (props) { - Object.entries(props).forEach(([key, value]) => { - if (value !== undefined && value !== null && key !== "children") { - let strValue = "" - if (typeof value === "string") { - strValue = value - } else if (typeof value === "object") { - strValue = JSON.stringify(value) - } else { - strValue = String(value) - } - xml += `\n${spaces} ${key}="${strValue}"` - } - }) - } - - if (!children || (Array.isArray(children) && children.length === 0)) { - xml += " />" - } else { - xml += ">" - - if (typeof children === "string") { - xml += `\n${spaces} ${children}\n${spaces}` - } else if (Array.isArray(children)) { - xml += "\n" - children.forEach((child) => { - xml += reactNodeToXML(child, indent + 1) + "\n" - }) - xml += spaces - } - - xml += `` - } - - return xml - } - - const xmlData = reactNodes.map((node) => reactNodeToXML(node)) - - return { - selectionCount: selection.length, - selection: selection.map((node) => ({ - id: node.id, - name: node.name, - type: node.type, - visible: node.visible, - })), - reactNodes, - variables: variablesData, - xml: xmlData, - } - } - - async function getNodeInfo(nodeId: string) { - const node = await figma.getNodeByIdAsync(nodeId) - if (!node) { - throw new Error(`Node not found with ID: ${nodeId}`) - } - - // exportAsync를 지원하는 노드인지 확인 - if ("exportAsync" in node) { - const response: any = await node.exportAsync({ - format: "JSON_REST_V1", - }) - return response?.document - } else { - return { - id: node.id, - name: node.name, - type: node.type, - ...("visible" in node ? { visible: node.visible } : {}), - } - } - } - - const figmaNodeToReactNode = async (figmaNode: SceneNode): Promise => { - let node: any - switch (figmaNode.type) { - case "INSTANCE": // 아이콘도 이 타입에 포함 - node = await instanceNodeToReactNode(figmaNode) - break - case "FRAME": - node = await frameNodeToReactNode(figmaNode) - break - case "TEXT": - node = await textNodeToReactNode(figmaNode) - break - case "RECTANGLE": - node = await rectangleNodeToReactNode(figmaNode) - break - case "GROUP": - node = await groupNodeToReactNode(figmaNode) - break - - default: - node = { - type: figmaNode.type, - props: { - id: figmaNode.id, - name: figmaNode.name, - }, - children: - "children" in figmaNode && figmaNode.children - ? figmaNode.children - : [], - } - } - - if ( - node.children && - Array.isArray(node.children) && - node.children.length > 0 - ) { - node.children = await Promise.all( - node.children - .filter((child: SceneNode) => child.visible) - .map((child: SceneNode) => figmaNodeToReactNode(child)), - ) - } - - return node - } - - const resolveVariableValue = async (variableId: string) => { - try { - const variable = await figma.variables.getVariableByIdAsync(variableId) - if (!variable) return null - - return { - id: variable.id, - name: variable.codeSyntax.WEB, - } - } catch (error) { - return { error: `Error resolving variable: ${error}` } - } - } - - const collectUsedVariables = async ( - node: SceneNode, - variableMap: Map = new Map(), - ): Promise> => { - if (!node.boundVariables) return variableMap - - for (const [property, aliases] of Object.entries(node.boundVariables)) { - const aliasList = Array.isArray(aliases) ? aliases : [aliases] - - for (const alias of aliasList) { - if (alias?.type === "VARIABLE_ALIAS" && alias.id) { - const variable = await resolveVariableValue(alias.id as string) - if (variable?.id && variable.name) { - const existing = variableMap.get(variable.id) - variableMap.set(variable.id, { - id: variable.id, - name: variable.name, - usedIn: [...(existing?.usedIn || []), `${node.name}.${property}`], - }) - } - } - } - } - - if ("children" in node && node.children) { - for (const child of node.children) { - if (child.visible) { - await collectUsedVariables(child, variableMap) - } - } - } - - return variableMap - } -} diff --git a/figma/plugin/src/server.ts b/figma/plugin/src/server.ts deleted file mode 100644 index 0bb25c91..00000000 --- a/figma/plugin/src/server.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" -import { v4 as uuidv4 } from "uuid" -import WebSocket from "ws" -import { z } from "zod" - -// Define TypeScript interfaces for Figma responses -interface FigmaResponse { - id: string - result?: any - error?: string -} - -// Custom logging functions that write to stderr instead of stdout to avoid being captured -const logger = { - info: (message: string) => process.stderr.write(`[INFO] ${message}\n`), - debug: (message: string) => process.stderr.write(`[DEBUG] ${message}\n`), - warn: (message: string) => process.stderr.write(`[WARN] ${message}\n`), - error: (message: string) => process.stderr.write(`[ERROR] ${message}\n`), - log: (message: string) => process.stderr.write(`[LOG] ${message}\n`), -} - -// WebSocket connection and request tracking -let ws: WebSocket | null = null -const pendingRequests = new Map< - string, - { - resolve: (value: any) => void - reject: (reason: any) => void - timeout: ReturnType - } ->() - -// Create MCP server -const server = new McpServer({ - name: "@jongh/figma-plugin", - version: "1.0.0", -}) -// Add command line argument parsing -const args = process.argv.slice(2) -const serverArg = args.find((arg) => arg.startsWith("--server=")) -const serverUrl = serverArg ? serverArg.split("=")[1] : "localhost" -const WS_URL = - serverUrl === "localhost" ? `ws://${serverUrl}` : `wss://${serverUrl}` - -// Document Info Tool -server.registerTool( - "get_document_info", - { - description: "Get detailed information about the current Figma document", - inputSchema: {}, - }, - async () => { - try { - const result = await sendCommandToFigma("get_document_info") - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting document info: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Selection Tool -server.registerTool( - "get_selection", - { - description: "Get information about the current selection in Figma", - inputSchema: {}, - }, - async () => { - try { - const result = await sendCommandToFigma("get_selection") - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting selection: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -// Node Info Tool -server.registerTool( - "get_node_info", - { - description: "Get detailed information about a specific node in Figma", - inputSchema: { - nodeId: z - .string() - .describe("The ID of the node to get information about"), - }, - }, - async ({ nodeId }) => { - try { - const result = await sendCommandToFigma("get_node_info", { nodeId }) - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error getting node info: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - } - } - }, -) - -server.prompt( - "connection_troubleshooting", - "Help users troubleshoot connection issues", - () => { - return { - messages: [ - { - role: "assistant", - content: { - type: "text", - text: `When users report connection issues, ask them to check: - -1. Figma Plugin Connection: - - Did you click the "Connect" button in the Figma plugin? - - Is the Figma plugin running and showing a connected status? - - Try refreshing the plugin or restarting it if needed - -2. MCP Connection in Cursor: - - Go to Cursor Settings/Preferences - - Check if the MCP server connection is properly configured(green light) - - Verify the MCP server is running and connected - - Look for any connection error messages in the settings - -If both are properly connected and issues persist, try: -- Restarting both the Figma plugin and Cursor -- Checking if the WebSocket connection is blocked by firewall -- Verifying the correct server URL and port configuration`, - }, - }, - ], - description: - "Troubleshooting guide for connection issues between Figma plugin and MCP server", - } - }, -) - -server.prompt( - "data_analysis_strategy", - "Best practices for analyzing Figma design data", - () => { - return { - messages: [ - { - role: "assistant", - content: { - type: "text", - text: `When analyzing Figma design data, follow these strategies - - 1. Data Extraction Workflow: - - Use get_selection() to focus on specific areas of interest - - Use get_document_info() when connection is enabled - - 2. Component Analysis: - - Identify reusable components vs one-off elements - - Look for design system patterns (colors, typography, spacing) - - Note component variants and their properties - - Extract design tokens (colors, fonts, spacing values) - - 3. Layout Analysis: - - Analyze auto-layout settings and constraints - - Document spacing patterns and grid systems - - Identify responsive design patterns - - Note alignment and positioning strategies - - - `, - }, - }, - ], - description: "Best practices for working with Figma designs", - } - }, -) - -// Define command types and parameters -type FigmaCommand = "get_document_info" | "get_selection" | "get_node_info" - -// Update the connectToFigma function -function connectToFigma(port = 3055) { - // If already connected, do nothing - if (ws && ws.readyState === WebSocket.OPEN) { - logger.info("Already connected to Figma") - return - } - - const wsUrl = serverUrl === "localhost" ? `${WS_URL}:${port}` : WS_URL - logger.info(`Connecting to Figma socket server at ${wsUrl}...`) - ws = new WebSocket(wsUrl) - - ws.on("open", () => { - logger.info("Connected to Figma socket server") - }) - - ws.on("message", (data: any) => { - try { - const json = JSON.parse(data) as { message: FigmaResponse } - const myResponse = json.message - logger.debug(`Received message: ${JSON.stringify(myResponse)}`) - logger.log("myResponse" + JSON.stringify(myResponse)) - - // Handle response to a request - if ( - myResponse.id && - pendingRequests.has(myResponse.id) && - myResponse.result - ) { - const request = pendingRequests.get(myResponse.id)! - clearTimeout(request.timeout) - - if (myResponse.error) { - logger.error(`Error from Figma: ${myResponse.error}`) - request.reject(new Error(myResponse.error)) - } else { - if (myResponse.result) { - request.resolve(myResponse.result) - } - } - - pendingRequests.delete(myResponse.id) - } else { - // Handle broadcast messages or events - logger.info(`Received broadcast message: ${JSON.stringify(myResponse)}`) - } - } catch (error) { - logger.error( - `Error parsing message: ${error instanceof Error ? error.message : String(error)}`, - ) - } - }) - - ws.on("error", (error) => { - logger.error(`Socket error: ${error}`) - }) - - ws.on("close", () => { - logger.info("Disconnected from Figma socket server") - ws = null - - // Reject all pending requests - for (const [id, request] of pendingRequests.entries()) { - clearTimeout(request.timeout) - request.reject(new Error("Connection closed")) - pendingRequests.delete(id) - } - - // Attempt to reconnect - logger.info("Attempting to reconnect in 2 seconds...") - setTimeout(() => connectToFigma(port), 2000) - }) -} - -// Function to send commands to Figma -function sendCommandToFigma( - command: FigmaCommand, - params: unknown = {}, -): Promise { - return new Promise((resolve, reject) => { - // If not connected, try to connect first - if (!ws || ws.readyState !== WebSocket.OPEN) { - connectToFigma() - reject(new Error("Not connected to Figma. Attempting to connect...")) - return - } - - const id = uuidv4() - const request = { - id, - type: "message", - message: { - id, - command, - params: { - ...(params as any), - }, - }, - } - - // Set timeout for request - const timeout = setTimeout(() => { - if (pendingRequests.has(id)) { - pendingRequests.delete(id) - logger.error(`Request ${id} to Figma timed out after 30 seconds`) - reject(new Error("Request to Figma timed out")) - } - }, 30000) // 30 second timeout - - // Store the promise callbacks to resolve/reject later - pendingRequests.set(id, { resolve, reject, timeout }) - - // Send the request - logger.info(`Sending command to Figma: ${command}`) - logger.debug(`Request details: ${JSON.stringify(request)}`) - ws.send(JSON.stringify(request)) - }) -} - -// Start the server -async function main() { - try { - // Try to connect to Figma socket server - connectToFigma() - } catch (error) { - logger.warn( - `Could not connect to Figma initially: ${error instanceof Error ? error.message : String(error)}`, - ) - logger.warn("Will try to connect when the first command is sent") - } - - // Start the MCP server with stdio transport - const transport = new StdioServerTransport() - await server.connect(transport) - logger.info("FigmaMCP server running on stdio") -} - -// Run the server -main().catch((error) => { - logger.error( - `Error starting FigmaMCP server: ${error instanceof Error ? error.message : String(error)}`, - ) - process.exit(1) -}) diff --git a/figma/plugin/src/socket.ts b/figma/plugin/src/socket.ts deleted file mode 100644 index 2d2379c0..00000000 --- a/figma/plugin/src/socket.ts +++ /dev/null @@ -1,180 +0,0 @@ -import http from "http" -import WebSocket, { WebSocketServer } from "ws" - -// Store clients by channel -const channels = new Map>() - -function handleConnection(ws: WebSocket) { - // Don't add to clients immediately - wait for channel join - console.log("New client connected") - - // Send welcome message to the new client - ws.send( - JSON.stringify({ - type: "system", - message: "Please join a channel to start chatting", - }), - ) - - const channelName = "default" - - if (!channelName || typeof channelName !== "string") { - ws.send( - JSON.stringify({ - type: "error", - message: "Channel name is required", - }), - ) - return - } - - // Create channel if it doesn't exist - if (!channels.has(channelName)) { - channels.set(channelName, new Set()) - } - - // Add client to channel - const channelClients = channels.get(channelName)! - channelClients.add(ws) - - // Notify client they joined successfully - ws.send( - JSON.stringify({ - type: "system", - message: `Joined channel: ${channelName}`, - channel: channelName, - }), - ) - - ws.send( - JSON.stringify({ - type: "system", - message: { - id: "default", - result: "Connected to channel: " + channelName, - }, - channel: channelName, - }), - ) - - // Notify other clients in channel - channelClients.forEach((client) => { - if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "system", - message: "A new user has joined the channel", - channel: channelName, - }), - ) - } - }) - - ws.on("close", () => { - console.log("Client disconnected") - - // Remove client from their channel - channels.forEach((clients, channelName) => { - if (clients.has(ws)) { - clients.delete(ws) - - // Notify other clients in same channel - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "system", - message: "A user has left the channel", - channel: channelName, - }), - ) - } - }) - } - }) - }) - - ws.on("message", (message) => { - try { - console.log("Received message from client:", message.toString()) - const data = JSON.parse(message.toString()) - - // Handle regular messages - if (data.type === "message") { - const channelName = data.channel || "default" - if (!channelName || typeof channelName !== "string") { - ws.send( - JSON.stringify({ - type: "error", - message: "Channel name is required", - }), - ) - return - } - const channelClients = channels.get(channelName) - if (!channelClients || !channelClients.has(ws)) { - ws.send( - JSON.stringify({ - type: "error", - message: "You must join the channel first", - }), - ) - return - } - - // Broadcast to all clients in the channel - channelClients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - console.log("Broadcasting message to client:", data.message) - client.send( - JSON.stringify({ - type: "broadcast", - message: data.message, - sender: client === ws ? "You" : "User", - channel: channelName, - }), - ) - } - }) - } - } catch (err) { - console.error("Error handling message:", err) - } - }) -} - -// Create HTTP server -const server = http.createServer((req, res) => { - // Handle CORS preflight - if (req.method === "OPTIONS") { - res.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - }) - res.end() - return - } - - // Return response for HTTP requests - res.writeHead(200, { - "Content-Type": "text/plain", - "Access-Control-Allow-Origin": "*", - }) - res.end("WebSocket server running") -}) - -// Create WebSocket server -const wss = new WebSocketServer({ server }) - -wss.on("connection", handleConnection) - -// Start server -const PORT = 3055 -server.listen(PORT, () => { - console.log(`WebSocket server running on port ${PORT}`) - // Uncomment this to log when running in WSL for debugging - // console.log('To connect from outside WSL, find your IP with "ip addr show"'); -}) - -console.log(`Socket server started on port ${PORT}`) diff --git a/figma/plugin/src/styles.css b/figma/plugin/src/styles.css deleted file mode 100644 index b6c12d56..00000000 --- a/figma/plugin/src/styles.css +++ /dev/null @@ -1,51 +0,0 @@ -:root { - font-family: Inter, system-ui, Helvetica, Arial, sans-serif; - font-display: optional; - font-size: 16px; -} - -html { - background-color: var(--figma-color-bg); - color: var(--figma-color-text); -} - -body { - font-size: 11px; -} - -* { - box-sizing: border-box; -} - -input { - border: none; - background-color: transparent; - font-size: inherit; -} - -button { - border: none; - background: transparent; - appearance: none; - font-size: inherit; - color: inherit; -} - -i18n-text { - display: contents; -} - -:root { - --radius-full: 9999px; - --radius-large: 0.8125rem; - --radius-medium: 0.3125rem; - --radius-none: 0; - --radius-small: 0.125rem; - --spacer-0: 0; - --spacer-1: 0.25rem; - --spacer-2: 0.5rem; - --spacer-3: 1rem; - --spacer-4: 1.5rem; - --spacer-5: 2rem; - --spacer-6: 2.5rem; -} diff --git a/figma/plugin/src/ui.tsx b/figma/plugin/src/ui.tsx deleted file mode 100644 index 789a8424..00000000 --- a/figma/plugin/src/ui.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import "./styles.css" - -import { StrictMode } from "react" -import { createRoot } from "react-dom/client" - -import App from "./app.tsx" - -createRoot(document.getElementById("app")!).render( - - - , -) diff --git a/figma/plugin/src/utils/convertNodeToXML.ts b/figma/plugin/src/utils/convertNodeToXML.ts deleted file mode 100644 index 86b2e0e4..00000000 --- a/figma/plugin/src/utils/convertNodeToXML.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { js2xml } from "xml-js" - -export async function convertNodeToXML( - selectedNodes: readonly SceneNode[], -): Promise { - if (!selectedNodes || !selectedNodes.length) { - return "" - } - - const processNode = (node: SceneNode): any => { - if (!node) { - return null - } - - const attributes: any = {} - - const flattenValue = (prefix: string, value: any) => { - if ( - value === figma.mixed || - typeof value === "symbol" || - typeof value === "function" - ) { - return - } - - if (typeof value === "object" && value !== null) { - if (Array.isArray(value)) { - if (value.length === 0) { - attributes[prefix] = "[]" - } else { - value.forEach((item, i) => { - flattenValue(`${prefix}_${i}`, item) - }) - } - } else { - const keys = Object.keys(value) - if (keys.length === 0) { - attributes[prefix] = "{}" - } else { - for (const key in value) { - if (Object.hasOwn(value, key)) { - flattenValue(`${prefix}_${key}`, value[key]) - } - } - } - } - } else { - attributes[prefix] = value - } - } - - const propertiesToExtract = [ - "id", - "name", - "visible", - "locked", - "opacity", - "blendMode", - "isMask", - "effects", - "effectStyleId", - "relativeTransform", - "x", - "y", - "width", - "height", - "rotation", - "layoutAlign", - "layoutGrow", - "absoluteBoundingBox", - "absoluteRenderBounds", - "constrainProportions", - "layoutMode", - "primaryAxisSizingMode", - "counterAxisSizingMode", - "primaryAxisAlignItems", - "counterAxisAlignItems", - "paddingLeft", - "paddingRight", - "paddingTop", - "paddingBottom", - "itemSpacing", - "fills", - "strokes", - "strokeWeight", - "strokeAlign", - "strokeCap", - "strokeJoin", - "dashPattern", - "fillStyleId", - "strokeStyleId", - "cornerRadius", - "cornerSmoothing", - "topLeftRadius", - "topRightRadius", - "bottomLeftRadius", - "bottomRightRadius", - "characters", - "fontSize", - "fontName", - "fontWeight", - "textCase", - "textDecoration", - "textAlignHorizontal", - "textAlignVertical", - "lineHeight", - "letterSpacing", - "mainComponent", - ] - - propertiesToExtract.forEach((key) => { - if (key in node) { - flattenValue(key, (node as any)[key]) - } - }) - - attributes["type"] = node.type - - const children = - "children" in node && - node.type !== "COMPONENT" && - node.type !== "INSTANCE" - ? node.children.map(processNode).filter((child) => child !== null) - : [] - - return { - type: "element", - name: node.type, - attributes, - elements: children.length > 0 ? children : undefined, - } - } - - const processedData = selectedNodes - .map(processNode) - .filter((data) => data !== null) - - const dataForXml = { - elements: [ - { - type: "element", - name: "root", - elements: processedData, - }, - ], - } - - const options = { - spaces: 2, - } - - const xmlResult = js2xml(dataForXml, options) - - return xmlResult -} diff --git a/figma/plugin/src/utils/map.ts b/figma/plugin/src/utils/map.ts deleted file mode 100644 index 62bd03aa..00000000 --- a/figma/plugin/src/utils/map.ts +++ /dev/null @@ -1,244 +0,0 @@ -// Figma to CSS 매핑 상수들 - -// ============================================================================= -// AUTO LAYOUT 매핑 -// ============================================================================= - -/** Figma layoutMode → CSS flex-direction */ -export const LAYOUT_MODE_TO_FLEX_DIRECTION = { - HORIZONTAL: "row", - VERTICAL: "column", - NONE: undefined, // auto layout이 아닌 경우 -} as const - -/** Figma primaryAxisAlignItems → CSS justify-content */ -export const PRIMARY_AXIS_ALIGN_TO_JUSTIFY_CONTENT = { - MIN: "flex-start", - MAX: "flex-end", - CENTER: "center", - SPACE_BETWEEN: "space-between", -} as const - -/** Figma counterAxisAlignItems → CSS align-items */ -export const COUNTER_AXIS_ALIGN_TO_ALIGN_ITEMS = { - MIN: "flex-start", - MAX: "flex-end", - CENTER: "center", - BASELINE: "baseline", // 베이스라인 정렬도 지원 -} as const - -/** Figma primaryAxisSizingMode → CSS flex 관련 */ -export const PRIMARY_AXIS_SIZING_MODE = { - FIXED: "none", // flex: none - AUTO: "1", // flex: 1 (grow) -} as const - -/** Figma counterAxisSizingMode → CSS width/height 동작 */ -export const COUNTER_AXIS_SIZING_MODE = { - FIXED: "fixed", // 고정 크기 - AUTO: "auto", // 컨텐츠에 맞춤 -} as const - -// ============================================================================= -// 텍스트 정렬 매핑 -// ============================================================================= - -/** Figma textAlignHorizontal → CSS text-align */ -export const TEXT_ALIGN_HORIZONTAL_TO_CSS = { - LEFT: "left", - CENTER: "center", - RIGHT: "right", - JUSTIFIED: "justify", -} as const - -/** Figma textAlignVertical → CSS vertical-align */ -export const TEXT_ALIGN_VERTICAL_TO_CSS = { - TOP: "top", - CENTER: "middle", - BOTTOM: "bottom", -} as const - -/** Figma textAutoResize → CSS 관련 동작 */ -export const TEXT_AUTO_RESIZE_TO_CSS = { - NONE: { width: "fixed", height: "fixed" }, - WIDTH_AND_HEIGHT: { width: "auto", height: "auto" }, - HEIGHT: { width: "fixed", height: "auto" }, - TRUNCATE: { overflow: "hidden", "text-overflow": "ellipsis" }, -} as const - -// ============================================================================= -// 텍스트 스타일 매핑 -// ============================================================================= - -/** Figma textDecoration → CSS text-decoration */ -export const TEXT_DECORATION_TO_CSS = { - UNDERLINE: "underline", - STRIKETHROUGH: "line-through", - NONE: "none", -} as const - -/** Figma textCase → CSS text-transform */ -export const TEXT_CASE_TO_CSS = { - ORIGINAL: "none", - UPPER: "uppercase", - LOWER: "lowercase", - TITLE: "capitalize", - SMALL_CAPS: "small-caps", -} as const - -/** Figma fontWeight → CSS font-weight */ -export const FONT_WEIGHT_TO_CSS = { - 100: "100", // Thin - 200: "200", // Extra Light - 300: "300", // Light - 400: "400", // Regular - 500: "500", // Medium - 600: "600", // Semi Bold - 700: "700", // Bold - 800: "800", // Extra Bold - 900: "900", // Black -} as const - -// ============================================================================= -// 레이아웃 관련 매핑 -// ============================================================================= - -/** Figma layoutAlign → CSS align-self */ -export const LAYOUT_ALIGN_TO_ALIGN_SELF = { - INHERIT: "auto", - STRETCH: "stretch", - MIN: "flex-start", - CENTER: "center", - MAX: "flex-end", -} as const - -/** Figma 제약조건 → CSS position 관련 */ -export const CONSTRAINT_TO_CSS = { - // Horizontal constraints - LEFT: "left", - RIGHT: "right", - LEFT_RIGHT: "left-right", - CENTER: "center", - SCALE: "scale", - - // Vertical constraints - TOP: "top", - BOTTOM: "bottom", - TOP_BOTTOM: "top-bottom", -} as const - -// ============================================================================= -// 효과 및 블렌드 매핑 -// ============================================================================= - -/** Figma blendMode → CSS mix-blend-mode */ -export const BLEND_MODE_TO_CSS = { - PASS_THROUGH: "normal", - NORMAL: "normal", - DARKEN: "darken", - MULTIPLY: "multiply", - LINEAR_BURN: "color-burn", - COLOR_BURN: "color-burn", - LIGHTEN: "lighten", - SCREEN: "screen", - LINEAR_DODGE: "color-dodge", - COLOR_DODGE: "color-dodge", - OVERLAY: "overlay", - SOFT_LIGHT: "soft-light", - HARD_LIGHT: "hard-light", - DIFFERENCE: "difference", - EXCLUSION: "exclusion", - HUE: "hue", - SATURATION: "saturation", - COLOR: "color", - LUMINOSITY: "luminosity", -} as const - -/** Figma strokeAlign → CSS border 처리 방식 */ -export const STROKE_ALIGN_TO_CSS = { - INSIDE: "inside", // box-sizing: border-box - OUTSIDE: "outside", // outline 사용 - CENTER: "center", // 기본 border 동작 -} as const - -/** Figma strokeCap → CSS stroke-linecap */ -export const STROKE_CAP_TO_CSS = { - NONE: "butt", - ROUND: "round", - SQUARE: "square", -} as const - -/** Figma strokeJoin → CSS stroke-linejoin */ -export const STROKE_JOIN_TO_CSS = { - MITER: "miter", - BEVEL: "bevel", - ROUND: "round", -} as const - -// ============================================================================= -// 단위 변환 매핑 -// ============================================================================= - -/** Figma lineHeight unit → CSS 단위 */ -export const LINE_HEIGHT_UNIT_TO_CSS = { - PIXELS: "px", - PERCENT: "%", - INTRINSIC_PERCENT: "%", - AUTO: "auto", -} as const - -/** Figma letterSpacing unit → CSS 단위 */ -export const LETTER_SPACING_UNIT_TO_CSS = { - PIXELS: "px", - PERCENT: "%", -} as const - -// ============================================================================= -// 타입 정의 -// ============================================================================= - -export type FigmaLayoutMode = keyof typeof LAYOUT_MODE_TO_FLEX_DIRECTION -export type FigmaPrimaryAxisAlign = - keyof typeof PRIMARY_AXIS_ALIGN_TO_JUSTIFY_CONTENT -export type FigmaCounterAxisAlign = - keyof typeof COUNTER_AXIS_ALIGN_TO_ALIGN_ITEMS -export type FigmaTextAlignHorizontal = keyof typeof TEXT_ALIGN_HORIZONTAL_TO_CSS -export type FigmaTextAlignVertical = keyof typeof TEXT_ALIGN_VERTICAL_TO_CSS -export type FigmaTextDecoration = keyof typeof TEXT_DECORATION_TO_CSS -export type FigmaTextCase = keyof typeof TEXT_CASE_TO_CSS -export type FigmaBlendMode = keyof typeof BLEND_MODE_TO_CSS -export type FigmaStrokeAlign = keyof typeof STROKE_ALIGN_TO_CSS - -// ============================================================================= -// 유틸리티 함수들 -// ============================================================================= - -/** 안전한 매핑 함수 - 매핑되지 않은 값에 대해 기본값 반환 */ -export function safeMapValue, K extends keyof T>( - mapping: T, - value: K | string, - fallback: T[K], -): T[K] { - return mapping[value as K] ?? fallback -} - -/** Figma 색상을 CSS 색상으로 변환 */ -export function figmaColorToCSS( - color: { r: number; g: number; b: number }, - opacity?: number, -): string { - const r = Math.round(color.r * 255) - const g = Math.round(color.g * 255) - const b = Math.round(color.b * 255) - - if (opacity !== undefined && opacity < 1) { - return `rgba(${r}, ${g}, ${b}, ${opacity})` - } - - return `rgb(${r}, ${g}, ${b})` -} - -/** Figma 값을 CSS 픽셀 단위로 변환 */ -export function figmaValueToPixels(value: number): string { - return `${Math.round(value)}px` -} diff --git a/figma/plugin/src/utils/node.ts b/figma/plugin/src/utils/node.ts deleted file mode 100644 index cdf9508b..00000000 --- a/figma/plugin/src/utils/node.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { - BLEND_MODE_TO_CSS, - COUNTER_AXIS_ALIGN_TO_ALIGN_ITEMS, - figmaColorToCSS, - figmaValueToPixels, - FONT_WEIGHT_TO_CSS, - LAYOUT_ALIGN_TO_ALIGN_SELF, - LAYOUT_MODE_TO_FLEX_DIRECTION, - PRIMARY_AXIS_ALIGN_TO_JUSTIFY_CONTENT, - safeMapValue, - TEXT_ALIGN_HORIZONTAL_TO_CSS, - TEXT_CASE_TO_CSS, - TEXT_DECORATION_TO_CSS, -} from "./map" - -// ============================================================================= -// 공통 스타일 변환 유틸리티 -// ============================================================================= - -/** Figma 노드의 공통 스타일을 CSS로 변환 */ -const convertCommonStyles = (node: SceneNode) => { - const styles: Record = {} - - // 기본 크기 - styles.width = figmaValueToPixels(node.width) - styles.height = figmaValueToPixels(node.height) - - // 위치 처리 - 부모의 레이아웃 모드에 따라 결정 - const parent = node.parent - if (parent) { - // 페이지 직속 자식은 위치값 무시 (캔버스 좌표) - if (parent.type === "PAGE") { - styles.position = "relative" - } - // Auto Layout 부모의 자식은 위치값 무시 (flexbox가 배치) - else if ("layoutMode" in parent && parent.layoutMode !== "NONE") { - // flexbox 자식은 위치값 불필요 - } - // 절대 위치 레이아웃의 자식은 위치값 사용 - else { - styles.position = "absolute" - styles.left = figmaValueToPixels(node.x) - styles.top = figmaValueToPixels(node.y) - } - } - - // 투명도 - if ("opacity" in node && node.opacity < 1) { - styles.opacity = node.opacity - } - - // 회전 - if ("rotation" in node && node.rotation !== 0) { - styles.transform = `rotate(${node.rotation}deg)` - } - - // 가시성 - if (!node.visible) { - styles.display = "none" - } - - // 블렌드 모드 - if ("blendMode" in node && node.blendMode !== "PASS_THROUGH") { - styles.mixBlendMode = safeMapValue( - BLEND_MODE_TO_CSS, - node.blendMode, - "normal", - ) - } - - return styles -} - -/** Auto Layout 스타일 변환 */ -const convertAutoLayoutStyles = ( - node: FrameNode | InstanceNode | ComponentNode, -) => { - const styles: Record = {} - - if (node.layoutMode !== "NONE") { - styles.display = "flex" - styles.flexDirection = LAYOUT_MODE_TO_FLEX_DIRECTION[node.layoutMode] - styles.justifyContent = - PRIMARY_AXIS_ALIGN_TO_JUSTIFY_CONTENT[node.primaryAxisAlignItems] - styles.alignItems = - COUNTER_AXIS_ALIGN_TO_ALIGN_ITEMS[node.counterAxisAlignItems] - - // 패딩 처리 (스마트 축약) - const { paddingTop, paddingRight, paddingBottom, paddingLeft } = node - if ( - paddingTop === paddingRight && - paddingTop === paddingBottom && - paddingTop === paddingLeft - ) { - if (paddingTop > 0) { - styles.padding = figmaValueToPixels(paddingTop) - } - } else if (paddingTop === paddingBottom && paddingLeft === paddingRight) { - styles.padding = `${figmaValueToPixels(paddingTop)} ${figmaValueToPixels(paddingLeft)}` - } else { - styles.padding = `${figmaValueToPixels(paddingTop)} ${figmaValueToPixels(paddingRight)} ${figmaValueToPixels(paddingBottom)} ${figmaValueToPixels(paddingLeft)}` - } - - // 간격 - if ( - node.itemSpacing > 0 && - node.primaryAxisAlignItems !== "SPACE_BETWEEN" - ) { - styles.gap = figmaValueToPixels(node.itemSpacing) - } - - // 레이아웃 래핑 - if ("layoutWrap" in node && node.layoutWrap === "WRAP") { - styles.flexWrap = "wrap" - } - - // Primary/Counter Axis Sizing Mode - if ( - "primaryAxisSizingMode" in node && - node.primaryAxisSizingMode === "AUTO" - ) { - if (node.layoutMode === "HORIZONTAL") { - styles.width = "auto" - } else { - styles.height = "auto" - } - } - - if ( - "counterAxisSizingMode" in node && - node.counterAxisSizingMode === "AUTO" - ) { - if (node.layoutMode === "HORIZONTAL") { - styles.height = "auto" - } else { - styles.width = "auto" - } - } - } - - return styles -} - -/** Layout 관련 스타일 변환 (Auto Layout이 아닌 경우에도 적용) */ -const convertLayoutStyles = (node: SceneNode) => { - const styles: Record = {} - - // Layout Align (자식 요소의 정렬) - if ("layoutAlign" in node && node.layoutAlign !== "INHERIT") { - styles.alignSelf = LAYOUT_ALIGN_TO_ALIGN_SELF[node.layoutAlign] - } - - // Layout Grow (flexbox grow) - if ("layoutGrow" in node && node.layoutGrow > 0) { - styles.flex = node.layoutGrow.toString() - } - - // Layout Sizing (반응형 크기) - if ("layoutSizingHorizontal" in node) { - switch (node.layoutSizingHorizontal) { - case "FILL": - styles.width = "100%" - break - case "HUG": - styles.width = "auto" - break - // FIXED는 기본값이므로 그대로 둠 - } - } - - if ("layoutSizingVertical" in node) { - switch (node.layoutSizingVertical) { - case "FILL": - styles.height = "100%" - break - case "HUG": - styles.height = "auto" - break - // FIXED는 기본값이므로 그대로 둠 - } - } - - return styles -} - -/** 텍스트 스타일 변환 */ -const convertTextStyles = (node: TextNode) => { - const styles: Record = {} - - // 폰트 관련 - if (typeof node.fontSize === "number") { - styles.fontSize = figmaValueToPixels(node.fontSize) - } - - if ("fontName" in node && typeof node.fontName === "object") { - styles.fontFamily = node.fontName.family - } - - if (typeof node.fontWeight === "number") { - styles.fontWeight = - FONT_WEIGHT_TO_CSS[node.fontWeight] || node.fontWeight.toString() - } - - // 텍스트 정렬 - styles.textAlign = TEXT_ALIGN_HORIZONTAL_TO_CSS[node.textAlignHorizontal] - styles.verticalAlign = safeMapValue( - { TOP: "top", CENTER: "middle", BOTTOM: "bottom" }, - node.textAlignVertical, - "top", - ) - - // 텍스트 장식 - if ("textDecoration" in node && node.textDecoration !== "NONE") { - styles.textDecoration = TEXT_DECORATION_TO_CSS[node.textDecoration] - } - - // 텍스트 케이스 - if ("textCase" in node && node.textCase !== "ORIGINAL") { - styles.textTransform = TEXT_CASE_TO_CSS[node.textCase] - } - - // 자간 - if ( - "letterSpacing" in node && - typeof node.letterSpacing === "object" && - node.letterSpacing.value !== 0 - ) { - styles.letterSpacing = - node.letterSpacing.unit === "PIXELS" - ? figmaValueToPixels(node.letterSpacing.value) - : `${node.letterSpacing.value}%` - } - - // 행간 - if ("lineHeight" in node && typeof node.lineHeight === "object") { - if (node.lineHeight.unit === "AUTO") { - styles.lineHeight = "auto" - } else { - styles.lineHeight = - node.lineHeight.unit === "PIXELS" - ? figmaValueToPixels(node.lineHeight.value) - : `${node.lineHeight.value}%` - } - } - - return styles -} - -/** 배경 및 테두리 스타일 변환 */ -const convertFillAndStrokeStyles = (node: any) => { - const styles: Record = {} - - // 배경 (fills) - if (node.fills && Array.isArray(node.fills) && node.fills.length > 0) { - const fill = node.fills[0] - if (fill.type === "SOLID") { - styles.backgroundColor = figmaColorToCSS(fill.color, fill.opacity) - } - } - - // 테두리 (strokes) - if (node.strokes && Array.isArray(node.strokes) && node.strokes.length > 0) { - const stroke = node.strokes[0] - if (stroke.type === "SOLID") { - const width = - "strokeWeight" in node ? figmaValueToPixels(node.strokeWeight) : "1px" - const color = figmaColorToCSS(stroke.color, stroke.opacity) - styles.border = `${width} solid ${color}` - } - } - - // 모서리 반경 - if ("cornerRadius" in node && node.cornerRadius) { - if (typeof node.cornerRadius === "number") { - styles.borderRadius = figmaValueToPixels(node.cornerRadius) - } else { - // 개별 모서리 반경 - const { - topLeftRadius, - topRightRadius, - bottomRightRadius, - bottomLeftRadius, - } = node - styles.borderRadius = `${figmaValueToPixels(topLeftRadius)} ${figmaValueToPixels(topRightRadius)} ${figmaValueToPixels(bottomRightRadius)} ${figmaValueToPixels(bottomLeftRadius)}` - } - } - - return styles -} - -const extractProps = (props: ComponentProperties) => { - return Object.entries( - Object.entries(props).map(([key, obj]) => [key, obj.value]), - ) -} - -const extractVariable = async ( - variableAlias: SceneNodeMixin["boundVariables"], -): Promise> => { - const result: Record = {} - - if (!variableAlias) { - return result - } - - for (const key of Object.keys(variableAlias)) { - const list = variableAlias[key] - if (Array.isArray(list)) { - result[key] = [] - for (const alias of list) { - if (alias && alias.id) { - try { - const variable: Variable = - await figma.variables.getVariableByIdAsync(alias.id) - result[key].push(variable.codeSyntax.WEB) - } catch (error) { - console.error("Variable resolution failed:", error) - } - } - } - } - } - - return result -} - -const resolveStyleWithVariables = async ( - style: any, - boundVariables: SceneNodeMixin["boundVariables"], -): Promise => { - if (!boundVariables) return style - - const resolvedStyle = { ...style } - const variableMap = await extractVariable(boundVariables) - - if (variableMap.fills && variableMap.fills.length > 0) { - resolvedStyle.fills = variableMap.fills - } - - if (variableMap.strokes && variableMap.strokes.length > 0) { - resolvedStyle.strokes = variableMap.strokes - } - - return resolvedStyle -} - -// ============================================================================= -// 통합된 스타일 변환 함수 -// ============================================================================= - -/** 모든 스타일을 통합하여 변환 */ -const convertNodeStyles = async (node: SceneNode) => { - let styles: Record = {} - - // 1. 공통 스타일 적용 - styles = { ...styles, ...convertCommonStyles(node) } - - // 2. Layout 관련 스타일 적용 (모든 노드에 적용) - styles = { ...styles, ...convertLayoutStyles(node) } - - // 3. 노드 타입별 스타일 적용 - if ( - node.type === "FRAME" || - node.type === "INSTANCE" || - node.type === "COMPONENT" - ) { - styles = { ...styles, ...convertAutoLayoutStyles(node as FrameNode) } - styles = { ...styles, ...convertFillAndStrokeStyles(node) } - } - - if (node.type === "TEXT") { - styles = { ...styles, ...convertTextStyles(node as TextNode) } - styles = { ...styles, ...convertFillAndStrokeStyles(node) } - - // 🔥 텍스트 특화 레이아웃 속성들 - const textNode = node as TextNode - - // Text Auto Resize - if ("textAutoResize" in textNode) { - switch (textNode.textAutoResize) { - case "WIDTH_AND_HEIGHT": - styles.width = "auto" - styles.height = "auto" - break - case "HEIGHT": - styles.height = "auto" - break - case "TRUNCATE": - styles.overflow = "hidden" - styles.textOverflow = "ellipsis" - styles.whiteSpace = "nowrap" - break - } - } - - // Max Lines (for text truncation) - if ("maxLines" in textNode && textNode.maxLines && textNode.maxLines > 1) { - styles.display = "-webkit-box" - styles.WebkitBoxOrient = "vertical" - styles.WebkitLineClamp = textNode.maxLines.toString() - styles.overflow = "hidden" - } - } - - if (node.type === "RECTANGLE") { - styles = { ...styles, ...convertFillAndStrokeStyles(node) } - } - - const resolvedStyles = await resolveStyleWithVariables( - styles, - node.boundVariables, - ) - - return resolvedStyles -} - -export const instanceNodeToReactNode = async ( - figmaNode: InstanceNode, -): Promise => { - const convertedStyles = await convertNodeStyles(figmaNode) - const css = await figmaNode.getCSSAsync() - - return { - type: figmaNode.name[0].toUpperCase() + figmaNode.name.slice(1), - props: { - ...extractProps(figmaNode.componentProperties), - id: figmaNode.id, - name: figmaNode.name, - style: convertedStyles, - css, - }, - boundVariables: figmaNode.boundVariables, - children: figmaNode.children || [], - } -} - -export const textNodeToReactNode = async ( - figmaNode: TextNode, -): Promise => { - const convertedStyles = await convertNodeStyles(figmaNode) - const css = await figmaNode.getCSSAsync() - - return { - type: "Text", - props: { - id: figmaNode.id, - name: figmaNode.name, - style: convertedStyles, - css, - }, - children: figmaNode.characters || "", - } -} - -export const rectangleNodeToReactNode = async ( - figmaNode: RectangleNode, -): Promise => { - const convertedStyles = await convertNodeStyles(figmaNode) - const css = await figmaNode.getCSSAsync() - - return { - type: "Rectangle", - props: { - id: figmaNode.id, - name: figmaNode.name, - style: convertedStyles, - css, - }, - } -} - -export const groupNodeToReactNode = async ( - figmaNode: GroupNode, -): Promise => { - return { - type: "Group", - props: { - id: figmaNode.id, - name: figmaNode.name, - }, - children: figmaNode.children || [], - } -} - -export const frameNodeToReactNode = async ( - figmaNode: FrameNode, -): Promise => { - const convertedStyles = await convertNodeStyles(figmaNode) - const css = await figmaNode.getCSSAsync() - - return { - type: "Frame", - props: { - id: figmaNode.id, - name: figmaNode.name, - style: convertedStyles, - boundVariables: figmaNode.boundVariables, - css, - }, - children: figmaNode.children || [], - } -} diff --git a/figma/plugin/src/vite-env.d.ts b/figma/plugin/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/figma/plugin/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/figma/plugin/tsconfig.json b/figma/plugin/tsconfig.json deleted file mode 100644 index b895f2c5..00000000 --- a/figma/plugin/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "typeRoots": ["node_modules/@figma", "node_modules/@types"] - }, - "include": ["src/**/*.ts", "src/**/*.tsx", "vite-env.d.ts"] -} diff --git a/figma/plugin/tsup.config.ts b/figma/plugin/tsup.config.ts deleted file mode 100644 index c29800b7..00000000 --- a/figma/plugin/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup" - -export default defineConfig({ - entry: ["src/server.ts"], - format: ["cjs"], - clean: true, - outDir: "dist/server", - target: "node18", - bundle: true, -}) diff --git a/figma/plugin/vite.config.js b/figma/plugin/vite.config.js deleted file mode 100644 index d5878337..00000000 --- a/figma/plugin/vite.config.js +++ /dev/null @@ -1,9 +0,0 @@ -import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" - -// https://vite.dev/config/ -export default defineConfig(() => { - return { - plugins: [react()], - } -}) From 2118bc8b8c394300fdabe66f513c6bc367f1fa34 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Mon, 9 Feb 2026 14:29:11 +0900 Subject: [PATCH 05/22] feat(animation-plugin): migrate from PandaCSS preset to Tailwind v4 plugin --- packages/panda-animation/CHANGELOG.md | 11 - packages/panda-animation/package.json | 58 +- packages/panda-animation/src/index.test.ts | 78 ++ packages/panda-animation/src/index.ts | 21 +- packages/panda-animation/src/keyframes.ts | 718 ++++++++++++++ .../src/tokens/keyframes/index.ts | 923 ------------------ packages/panda-animation/tsup.config.ts | 11 - 7 files changed, 820 insertions(+), 1000 deletions(-) delete mode 100644 packages/panda-animation/CHANGELOG.md create mode 100644 packages/panda-animation/src/index.test.ts create mode 100644 packages/panda-animation/src/keyframes.ts delete mode 100644 packages/panda-animation/src/tokens/keyframes/index.ts delete mode 100644 packages/panda-animation/tsup.config.ts diff --git a/packages/panda-animation/CHANGELOG.md b/packages/panda-animation/CHANGELOG.md deleted file mode 100644 index 9addf1dd..00000000 --- a/packages/panda-animation/CHANGELOG.md +++ /dev/null @@ -1,11 +0,0 @@ -## 0.0.2 - -## 0.1.0 - -### Minor Changes - -- 2b064ae: add contentShow animation for dialog - -### Patch Changes - -- 7b2feec: animation feature for pandacss animation diff --git a/packages/panda-animation/package.json b/packages/panda-animation/package.json index f849cd17..786d1e72 100644 --- a/packages/panda-animation/package.json +++ b/packages/panda-animation/package.json @@ -1,60 +1,22 @@ { - "name": "panda-animation", + "name": "@jongh/animation-plugin", "version": "0.1.0", - "description": "animation preset for pandacss", "type": "module", - "keywords": [], - "author": "", - "license": "ISC", + "description": "Tailwind v4 animation plugin with animate.css keyframes", "main": "./src/index.ts", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist", - "package.json" - ], - "clean-package": { - "replace": { - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - } - } + "exports": { + ".": "./src/index.ts" }, "scripts": { - "release": "pnpm build && pnpm publish --no-git-checks", - "prepack": "clean-package", - "postpack": "clean-package restore", + "lint": "eslint --max-warnings 0 --no-warn-ignored --fix", "check-type": "tsgo --noEmit", - "lint": "eslint --max-warnings 0 --fix", - "build": "tsup" - }, - "tsup": { - "entry": [ - "src/preset.ts" - ], - "clean": true, - "dts": true, - "sourcemap": true, - "format": [ - "esm", - "cjs" - ] + "test": "vitest run" }, "devDependencies": { - "clean-package": "^2.2.0", - "tsup": "^8.3.0", - "@jongh/tsconfig": "workspace:*", "@jongh/eslint": "workspace:*", - "@pandacss/dev": "catalog:" + "@jongh/tsconfig": "workspace:*" + }, + "dependencies": { + "tailwindcss": "^4.1.12" } } diff --git a/packages/panda-animation/src/index.test.ts b/packages/panda-animation/src/index.test.ts new file mode 100644 index 00000000..6895d6ca --- /dev/null +++ b/packages/panda-animation/src/index.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs" +import { compile } from "tailwindcss" +import { describe, expect, it } from "vitest" + +import plugin from "./index" + +const tailwindBase = readFileSync( + require.resolve("tailwindcss/index.css"), + "utf-8", +) + +async function buildCss(candidates: string[]) { + const compiler = await compile( + `@import "tailwindcss"; @plugin "./animation-plugin";`, + { + loadModule: async (_id, base) => ({ + path: "./animation-plugin", + base, + module: plugin(), + }), + loadStylesheet: async (id, base) => { + if (id === "tailwindcss") { + return { path: "tailwindcss/index.css", base, content: tailwindBase } + } + throw new Error(`Unknown stylesheet: ${id}`) + }, + }, + ) + return compiler.build(candidates) +} + +describe("애니메이션 플러그인 CSS 생성 검증", () => { + it("fade-in @keyframes가 CSS 출력에 포함된다", async () => { + const css = await buildCss([]) + expect(css).toContain("@keyframes fade-in") + }) + + it("bounce @keyframes가 CSS 출력에 포함된다", async () => { + const css = await buildCss([]) + expect(css).toContain("@keyframes bounce") + }) + + it("accordion-down @keyframes가 CSS 출력에 포함된다", async () => { + const css = await buildCss([]) + expect(css).toContain("@keyframes accordion-down") + expect(css).toContain("var(--radix-accordion-content-height)") + }) + + it("animate-fade-in 클래스가 animation CSS를 생성한다", async () => { + const css = await buildCss(["animate-fade-in"]) + expect(css).toContain("animation:") + expect(css).toContain("fade-in") + }) + + it("animate-bounce 클래스가 animation CSS를 생성한다", async () => { + const css = await buildCss(["animate-bounce"]) + expect(css).toContain("animation:") + expect(css).toContain("bounce") + }) + + it("주요 애니메이션 9종의 @keyframes가 모두 등록된다", async () => { + const css = await buildCss([]) + const names = [ + "fade-in", + "fade-out", + "slide-in-up", + "zoom-in", + "flip", + "bounce", + "tada", + "wobble", + "content-show", + ] + for (const name of names) { + expect(css).toContain(`@keyframes ${name}`) + } + }) +}) diff --git a/packages/panda-animation/src/index.ts b/packages/panda-animation/src/index.ts index 9fcc1e03..3c6729ca 100644 --- a/packages/panda-animation/src/index.ts +++ b/packages/panda-animation/src/index.ts @@ -1,12 +1,19 @@ -import { definePreset } from "@pandacss/dev" +import plugin from "tailwindcss/plugin" -import { keyframes } from "./tokens/keyframes" -export const preset = () => - definePreset({ - name: "panda-animation", +import { animations, keyframes } from "./keyframes" + +export default plugin.withOptions( + () => + ({ addBase }) => { + for (const [name, steps] of Object.entries(keyframes)) { + addBase({ [`@keyframes ${name}`]: steps }) + } + }, + () => ({ theme: { extend: { - keyframes, + animation: animations, }, }, - }) + }), +) diff --git a/packages/panda-animation/src/keyframes.ts b/packages/panda-animation/src/keyframes.ts new file mode 100644 index 00000000..7e9cd804 --- /dev/null +++ b/packages/panda-animation/src/keyframes.ts @@ -0,0 +1,718 @@ +type KeyframeStep = Record +type Keyframe = Record + +export const keyframes: Record = { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + bounce: { + "0%, 20%, 53%, to": { + "animation-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + transform: "translateZ(0)", + }, + "40%, 43%": { + "animation-timing-function": "cubic-bezier(0.755, 0.05, 0.855, 0.06)", + transform: "translate3d(0, -30px, 0) scaleY(1.1)", + }, + "70%": { + "animation-timing-function": "cubic-bezier(0.755, 0.05, 0.855, 0.06)", + transform: "translate3d(0, -15px, 0) scaleY(1.05)", + }, + "80%": { + "transition-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + transform: "translateZ(0) scaleY(0.95)", + }, + "90%": { transform: "translate3d(0, -4px, 0) scaleY(1.02)" }, + }, + flash: { + "0%, 50%, to": { opacity: "1" }, + "25%, 75%": { opacity: "0" }, + }, + pulse: { + "0%": { transform: "scaleX(1)" }, + "50%": { transform: "scale3d(1.05, 1.05, 1.05)" }, + to: { transform: "scaleX(1)" }, + }, + "rubber-band": { + "0%": { transform: "scaleX(1)" }, + "30%": { transform: "scale3d(1.25, 0.75, 1)" }, + "40%": { transform: "scale3d(0.75, 1.25, 1)" }, + "50%": { transform: "scale3d(1.15, 0.85, 1)" }, + "65%": { transform: "scale3d(0.95, 1.05, 1)" }, + "75%": { transform: "scale3d(1.05, 0.95, 1)" }, + to: { transform: "scaleX(1)" }, + }, + "shake-x": { + "0%, to": { transform: "translateZ(0)" }, + "10%, 30%, 50%, 70%, 90%": { transform: "translate3d(-10px, 0, 0)" }, + "20%, 40%, 60%, 80%": { transform: "translate3d(10px, 0, 0)" }, + }, + "shake-y": { + "0%, to": { transform: "translateZ(0)" }, + "10%, 30%, 50%, 70%, 90%": { transform: "translate3d(0, -10px, 0)" }, + "20%, 40%, 60%, 80%": { transform: "translate3d(0, 10px, 0)" }, + }, + "head-shake": { + "0%": { transform: "translateX(0)" }, + "6.5%": { transform: "translateX(-6px) rotateY(-9deg)" }, + "18.5%": { transform: "translateX(5px) rotateY(7deg)" }, + "31.5%": { transform: "translateX(-3px) rotateY(-5deg)" }, + "43.5%": { transform: "translateX(2px) rotateY(3deg)" }, + "50%": { transform: "translateX(0)" }, + }, + swing: { + "20%": { transform: "rotate(15deg)" }, + "40%": { transform: "rotate(-10deg)" }, + "60%": { transform: "rotate(5deg)" }, + "80%": { transform: "rotate(-5deg)" }, + to: { transform: "rotate(0deg)" }, + }, + tada: { + "0%": { transform: "scaleX(1)" }, + "10%, 20%": { transform: "scale3d(0.9, 0.9, 0.9) rotate(-3deg)" }, + "30%, 50%, 70%, 90%": { transform: "scale3d(1.1, 1.1, 1.1) rotate(3deg)" }, + "40%, 60%, 80%": { transform: "scale3d(1.1, 1.1, 1.1) rotate(-3deg)" }, + to: { transform: "scaleX(1)" }, + }, + wobble: { + "0%": { transform: "translateZ(0)" }, + "15%": { transform: "translate3d(-25%, 0, 0) rotate(-5deg)" }, + "30%": { transform: "translate3d(20%, 0, 0) rotate(3deg)" }, + "45%": { transform: "translate3d(-15%, 0, 0) rotate(-3deg)" }, + "60%": { transform: "translate3d(10%, 0, 0) rotate(2deg)" }, + "75%": { transform: "translate3d(-5%, 0, 0) rotate(-1deg)" }, + to: { transform: "translateZ(0)" }, + }, + jello: { + "0%, 11.1%, to": { transform: "translateZ(0)" }, + "22.2%": { transform: "skewX(-12.5deg) skewY(-12.5deg)" }, + "33.3%": { transform: "skewX(6.25deg) skewY(6.25deg)" }, + "44.4%": { transform: "skewX(-3.125deg) skewY(-3.125deg)" }, + "55.5%": { transform: "skewX(1.5625deg) skewY(1.5625deg)" }, + "66.6%": { transform: "skewX(-0.78125deg) skewY(-0.78125deg)" }, + "77.7%": { transform: "skewX(0.390625deg) skewY(0.390625deg)" }, + "88.8%": { transform: "skewX(-0.1953125deg) skewY(-0.1953125deg)" }, + }, + "heart-beat": { + "0%": { transform: "scale(1)" }, + "14%": { transform: "scale(1.3)" }, + "28%": { transform: "scale(1)" }, + "42%": { transform: "scale(1.3)" }, + "70%": { transform: "scale(1)" }, + }, + "back-in-down": { + "0%": { transform: "translateY(-1200px) scale(0.7)", opacity: "0.7" }, + "80%": { transform: "translateY(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "scale(1)", opacity: "1" }, + }, + "back-in-left": { + "0%": { transform: "translateX(-2000px) scale(0.7)", opacity: "0.7" }, + "80%": { transform: "translateX(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "scale(1)", opacity: "1" }, + }, + "back-in-right": { + "0%": { transform: "translateX(2000px) scale(0.7)", opacity: "0.7" }, + "80%": { transform: "translateX(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "scale(1)", opacity: "1" }, + }, + "back-in-up": { + "0%": { transform: "translateY(1200px) scale(0.7)", opacity: "0.7" }, + "80%": { transform: "translateY(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "scale(1)", opacity: "1" }, + }, + "back-out-down": { + "0%": { transform: "scale(1)", opacity: "1" }, + "20%": { transform: "translateY(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "translateY(700px) scale(0.7)", opacity: "0.7" }, + }, + "back-out-left": { + "0%": { transform: "scale(1)", opacity: "1" }, + "20%": { transform: "translateX(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "translateX(-2000px) scale(0.7)", opacity: "0.7" }, + }, + "back-out-right": { + "0%": { transform: "scale(1)", opacity: "1" }, + "20%": { transform: "translateX(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "translateX(2000px) scale(0.7)", opacity: "0.7" }, + }, + "back-out-up": { + "0%": { transform: "scale(1)", opacity: "1" }, + "20%": { transform: "translateY(0) scale(0.7)", opacity: "0.7" }, + to: { transform: "translateY(-700px) scale(0.7)", opacity: "0.7" }, + }, + "bounce-in": { + "0%, 20%, 40%, 60%, 80%, to": { + "animation-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + }, + "0%": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" }, + "20%": { transform: "scale3d(1.1, 1.1, 1.1)" }, + "40%": { transform: "scale3d(0.9, 0.9, 0.9)" }, + "60%": { opacity: "1", transform: "scale3d(1.03, 1.03, 1.03)" }, + "80%": { transform: "scale3d(0.97, 0.97, 0.97)" }, + to: { opacity: "1", transform: "scaleX(1)" }, + }, + "bounce-in-down": { + "0%, 60%, 75%, 90%, to": { + "animation-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + }, + "0%": { opacity: "0", transform: "translate3d(0, -3000px, 0) scaleY(3)" }, + "60%": { opacity: "1", transform: "translate3d(0, 25px, 0) scaleY(0.9)" }, + "75%": { transform: "translate3d(0, -10px, 0) scaleY(0.95)" }, + "90%": { transform: "translate3d(0, 5px, 0) scaleY(0.985)" }, + to: { transform: "translateZ(0)" }, + }, + "bounce-in-left": { + "0%, 60%, 75%, 90%, to": { + "animation-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + }, + "0%": { opacity: "0", transform: "translate3d(-3000px, 0, 0) scaleX(3)" }, + "60%": { opacity: "1", transform: "translate3d(25px, 0, 0) scaleX(1)" }, + "75%": { transform: "translate3d(-10px, 0, 0) scaleX(0.98)" }, + "90%": { transform: "translate3d(5px, 0, 0) scaleX(0.995)" }, + to: { transform: "translateZ(0)" }, + }, + "bounce-in-right": { + "0%, 60%, 75%, 90%, to": { + "animation-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + }, + "0%": { opacity: "0", transform: "translate3d(3000px, 0, 0) scaleX(3)" }, + "60%": { opacity: "1", transform: "translate3d(-25px, 0, 0) scaleX(1)" }, + "75%": { transform: "translate3d(10px, 0, 0) scaleX(0.98)" }, + "90%": { transform: "translate3d(-5px, 0, 0) scaleX(0.995)" }, + to: { transform: "translateZ(0)" }, + }, + "bounce-in-up": { + "0%, 60%, 75%, 90%, to": { + "animation-timing-function": "cubic-bezier(0.215, 0.61, 0.355, 1)", + }, + "0%": { opacity: "0", transform: "translate3d(0, 3000px, 0) scaleY(5)" }, + "60%": { opacity: "1", transform: "translate3d(0, -20px, 0) scaleY(0.9)" }, + "75%": { transform: "translate3d(0, 10px, 0) scaleY(0.95)" }, + "90%": { transform: "translate3d(0, -5px, 0) scaleY(0.985)" }, + to: { transform: "translateZ(0)" }, + }, + "bounce-out": { + "20%": { transform: "scale3d(0.9, 0.9, 0.9)" }, + "50%, 55%": { opacity: "1", transform: "scale3d(1.1, 1.1, 1.1)" }, + to: { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" }, + }, + "bounce-out-down": { + "20%": { transform: "translate3d(0, 10px, 0) scaleY(0.985)" }, + "40%, 45%": { + opacity: "1", + transform: "translate3d(0, -20px, 0) scaleY(0.9)", + }, + to: { opacity: "0", transform: "translate3d(0, 2000px, 0) scaleY(3)" }, + }, + "bounce-out-left": { + "20%": { opacity: "1", transform: "translate3d(20px, 0, 0) scaleX(0.9)" }, + to: { opacity: "0", transform: "translate3d(-2000px, 0, 0) scaleX(2)" }, + }, + "bounce-out-right": { + "20%": { opacity: "1", transform: "translate3d(-20px, 0, 0) scaleX(0.9)" }, + to: { opacity: "0", transform: "translate3d(2000px, 0, 0) scaleX(2)" }, + }, + "bounce-out-up": { + "20%": { transform: "translate3d(0, -10px, 0) scaleY(0.985)" }, + "40%, 45%": { + opacity: "1", + transform: "translate3d(0, 20px, 0) scaleY(0.9)", + }, + to: { opacity: "0", transform: "translate3d(0, -2000px, 0) scaleY(3)" }, + }, + "fade-in": { "0%": { opacity: "0" }, to: { opacity: "1" } }, + "fade-in-down": { + "0%": { opacity: "0", transform: "translate3d(0, -100%, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-down-big": { + "0%": { opacity: "0", transform: "translate3d(0, -2000px, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-left": { + "0%": { opacity: "0", transform: "translate3d(-100%, 0, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-left-big": { + "0%": { opacity: "0", transform: "translate3d(-2000px, 0, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-right": { + "0%": { opacity: "0", transform: "translate3d(100%, 0, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-right-big": { + "0%": { opacity: "0", transform: "translate3d(2000px, 0, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-up": { + "0%": { opacity: "0", transform: "translate3d(0, 100%, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-up-big": { + "0%": { opacity: "0", transform: "translate3d(0, 2000px, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-top-left": { + "0%": { opacity: "0", transform: "translate3d(-100%, -100%, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-top-right": { + "0%": { opacity: "0", transform: "translate3d(100%, -100%, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-bottom-left": { + "0%": { opacity: "0", transform: "translate3d(-100%, 100%, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-in-bottom-right": { + "0%": { opacity: "0", transform: "translate3d(100%, 100%, 0)" }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "fade-out": { "0%": { opacity: "1" }, to: { opacity: "0" } }, + "fade-out-down": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(0, 100%, 0)" }, + }, + "fade-out-down-big": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(0, 2000px, 0)" }, + }, + "fade-out-left": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(-100%, 0, 0)" }, + }, + "fade-out-left-big": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(-2000px, 0, 0)" }, + }, + "fade-out-right": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(100%, 0, 0)" }, + }, + "fade-out-right-big": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(2000px, 0, 0)" }, + }, + "fade-out-up": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(0, -100%, 0)" }, + }, + "fade-out-up-big": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(0, -2000px, 0)" }, + }, + "fade-out-top-left": { + "0%": { opacity: "1", transform: "translateZ(0)" }, + to: { opacity: "0", transform: "translate3d(-100%, -100%, 0)" }, + }, + "fade-out-top-right": { + "0%": { opacity: "1", transform: "translateZ(0)" }, + to: { opacity: "0", transform: "translate3d(100%, -100%, 0)" }, + }, + "fade-out-bottom-right": { + "0%": { opacity: "1", transform: "translateZ(0)" }, + to: { opacity: "0", transform: "translate3d(100%, 100%, 0)" }, + }, + "fade-out-bottom-left": { + "0%": { opacity: "1", transform: "translateZ(0)" }, + to: { opacity: "0", transform: "translate3d(-100%, 100%, 0)" }, + }, + flip: { + "0%": { + transform: "perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn)", + "animation-timing-function": "ease-out", + }, + "40%": { + transform: + "perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg)", + "animation-timing-function": "ease-out", + }, + "50%": { + transform: + "perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg)", + "animation-timing-function": "ease-in", + }, + "80%": { + transform: + "perspective(400px) scale3d(0.95, 0.95, 0.95) translateZ(0) rotateY(0deg)", + "animation-timing-function": "ease-in", + }, + to: { + transform: "perspective(400px) scaleX(1) translateZ(0) rotateY(0deg)", + "animation-timing-function": "ease-in", + }, + }, + "flip-in-x": { + "0%": { + transform: "perspective(400px) rotateX(90deg)", + "animation-timing-function": "ease-in", + opacity: "0", + }, + "40%": { + transform: "perspective(400px) rotateX(-20deg)", + "animation-timing-function": "ease-in", + }, + "60%": { transform: "perspective(400px) rotateX(10deg)", opacity: "1" }, + "80%": { transform: "perspective(400px) rotateX(-5deg)" }, + to: { transform: "perspective(400px)" }, + }, + "flip-in-y": { + "0%": { + transform: "perspective(400px) rotateY(90deg)", + "animation-timing-function": "ease-in", + opacity: "0", + }, + "40%": { + transform: "perspective(400px) rotateY(-20deg)", + "animation-timing-function": "ease-in", + }, + "60%": { transform: "perspective(400px) rotateY(10deg)", opacity: "1" }, + "80%": { transform: "perspective(400px) rotateY(-5deg)" }, + to: { transform: "perspective(400px)" }, + }, + "flip-out-x": { + "0%": { transform: "perspective(400px)" }, + "30%": { transform: "perspective(400px) rotateX(-20deg)", opacity: "1" }, + to: { transform: "perspective(400px) rotateX(90deg)", opacity: "0" }, + }, + "flip-out-y": { + "0%": { transform: "perspective(400px)" }, + "30%": { transform: "perspective(400px) rotateY(-15deg)", opacity: "1" }, + to: { transform: "perspective(400px) rotateY(90deg)", opacity: "0" }, + }, + "light-speed-in-right": { + "0%": { transform: "translate3d(100%, 0, 0) skewX(-30deg)", opacity: "0" }, + "60%": { transform: "skewX(20deg)", opacity: "1" }, + "80%": { transform: "skewX(-5deg)" }, + to: { transform: "translateZ(0)" }, + }, + "light-speed-in-left": { + "0%": { transform: "translate3d(-100%, 0, 0) skewX(30deg)", opacity: "0" }, + "60%": { transform: "skewX(-20deg)", opacity: "1" }, + "80%": { transform: "skewX(5deg)" }, + to: { transform: "translateZ(0)" }, + }, + "light-speed-out-right": { + "0%": { opacity: "1" }, + to: { transform: "translate3d(100%, 0, 0) skewX(30deg)", opacity: "0" }, + }, + "light-speed-out-left": { + "0%": { opacity: "1" }, + to: { transform: "translate3d(-100%, 0, 0) skewX(-30deg)", opacity: "0" }, + }, + "rotate-in": { + "0%": { transform: "rotate(-200deg)", opacity: "0" }, + to: { transform: "translateZ(0)", opacity: "1" }, + }, + "rotate-in-down-left": { + "0%": { transform: "rotate(-45deg)", opacity: "0" }, + to: { transform: "translateZ(0)", opacity: "1" }, + }, + "rotate-in-down-right": { + "0%": { transform: "rotate(45deg)", opacity: "0" }, + to: { transform: "translateZ(0)", opacity: "1" }, + }, + "rotate-in-up-left": { + "0%": { transform: "rotate(45deg)", opacity: "0" }, + to: { transform: "translateZ(0)", opacity: "1" }, + }, + "rotate-in-up-right": { + "0%": { transform: "rotate(-90deg)", opacity: "0" }, + to: { transform: "translateZ(0)", opacity: "1" }, + }, + "rotate-out": { + "0%": { opacity: "1" }, + to: { transform: "rotate(200deg)", opacity: "0" }, + }, + "rotate-out-down-left": { + "0%": { opacity: "1" }, + to: { transform: "rotate(45deg)", opacity: "0" }, + }, + "rotate-out-down-right": { + "0%": { opacity: "1" }, + to: { transform: "rotate(-45deg)", opacity: "0" }, + }, + "rotate-out-up-left": { + "0%": { opacity: "1" }, + to: { transform: "rotate(-45deg)", opacity: "0" }, + }, + "rotate-out-up-right": { + "0%": { opacity: "1" }, + to: { transform: "rotate(90deg)", opacity: "0" }, + }, + hinge: { + "0%": { "animation-timing-function": "ease-in-out" }, + "20%, 60%": { + transform: "rotate(80deg)", + "animation-timing-function": "ease-in-out", + }, + "40%, 80%": { + transform: "rotate(60deg)", + "animation-timing-function": "ease-in-out", + opacity: "1", + }, + to: { transform: "translate3d(0, 700px, 0)", opacity: "0" }, + }, + "jack-in-the-box": { + "0%": { + opacity: "0", + transform: "scale(0.1) rotate(30deg)", + "transform-origin": "center bottom", + }, + "50%": { transform: "rotate(-10deg)" }, + "70%": { transform: "rotate(3deg)" }, + to: { opacity: "1", transform: "scale(1)" }, + }, + "roll-in": { + "0%": { + opacity: "0", + transform: "translate3d(-100%, 0, 0) rotate(-120deg)", + }, + to: { opacity: "1", transform: "translateZ(0)" }, + }, + "roll-out": { + "0%": { opacity: "1" }, + to: { opacity: "0", transform: "translate3d(100%, 0, 0) rotate(120deg)" }, + }, + "zoom-in": { + "0%": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" }, + "50%": { opacity: "1" }, + }, + "zoom-in-down": { + "0%": { + opacity: "0", + transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, -1000px, 0)", + "animation-timing-function": "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + }, + "60%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0)", + "animation-timing-function": "cubic-bezier(0.175, 0.885, 0.32, 1)", + }, + }, + "zoom-in-left": { + "0%": { + opacity: "0", + transform: "scale3d(0.1, 0.1, 0.1) translate3d(-1000px, 0, 0)", + "animation-timing-function": "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + }, + "60%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(10px, 0, 0)", + "animation-timing-function": "cubic-bezier(0.175, 0.885, 0.32, 1)", + }, + }, + "zoom-in-right": { + "0%": { + opacity: "0", + transform: "scale3d(0.1, 0.1, 0.1) translate3d(1000px, 0, 0)", + "animation-timing-function": "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + }, + "60%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(-10px, 0, 0)", + "animation-timing-function": "cubic-bezier(0.175, 0.885, 0.32, 1)", + }, + }, + "zoom-in-up": { + "0%": { + opacity: "0", + transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, 1000px, 0)", + "animation-timing-function": "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + }, + "60%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0)", + "animation-timing-function": "cubic-bezier(0.175, 0.885, 0.32, 1)", + }, + }, + "zoom-out": { + "0%": { opacity: "1" }, + "50%": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" }, + to: { opacity: "0" }, + }, + "zoom-out-down": { + "40%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0)", + "animation-timing-function": "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + }, + to: { + opacity: "0", + transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, 2000px, 0)", + "animation-timing-function": "cubic-bezier(0.175, 0.885, 0.32, 1)", + }, + }, + "zoom-out-left": { + "40%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(42px, 0, 0)", + }, + to: { opacity: "0", transform: "scale(0.1) translate3d(-2000px, 0, 0)" }, + }, + "zoom-out-right": { + "40%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(-42px, 0, 0)", + }, + to: { opacity: "0", transform: "scale(0.1) translate3d(2000px, 0, 0)" }, + }, + "zoom-out-up": { + "40%": { + opacity: "1", + transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0)", + "animation-timing-function": "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + }, + to: { + opacity: "0", + transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, -2000px, 0)", + "animation-timing-function": "cubic-bezier(0.175, 0.885, 0.32, 1)", + }, + }, + "slide-in-down": { + "0%": { transform: "translate3d(0, -100%, 0)", visibility: "visible" }, + to: { transform: "translateZ(0)" }, + }, + "slide-in-left": { + "0%": { transform: "translate3d(-100%, 0, 0)", visibility: "visible" }, + to: { transform: "translateZ(0)" }, + }, + "slide-in-right": { + "0%": { transform: "translate3d(100%, 0, 0)", visibility: "visible" }, + to: { transform: "translateZ(0)" }, + }, + "slide-in-up": { + "0%": { transform: "translate3d(0, 100%, 0)", visibility: "visible" }, + to: { transform: "translateZ(0)" }, + }, + "slide-out-down": { + "0%": { transform: "translateZ(0)" }, + to: { visibility: "hidden", transform: "translate3d(0, 100%, 0)" }, + }, + "slide-out-left": { + "0%": { transform: "translateZ(0)" }, + to: { visibility: "hidden", transform: "translate3d(-100%, 0, 0)" }, + }, + "slide-out-right": { + "0%": { transform: "translateZ(0)" }, + to: { visibility: "hidden", transform: "translate3d(100%, 0, 0)" }, + }, + "slide-out-up": { + "0%": { transform: "translateZ(0)" }, + to: { visibility: "hidden", transform: "translate3d(0, -100%, 0)" }, + }, + "content-show": { + "0%": { opacity: "0", transform: "translate(-50%, -48%) scale(0.96)" }, + to: { opacity: "1", transform: "translate(-50%, -50%) scale(1)" }, + }, +} + +/** Default animation shorthand for each keyframe name */ +export const animations: Record = { + "accordion-down": "accordion-down 200ms ease-out", + "accordion-up": "accordion-up 200ms ease-out", + bounce: "bounce 1s ease infinite", + flash: "flash 1s ease", + pulse: "pulse 1s ease infinite", + "rubber-band": "rubber-band 1s ease", + "shake-x": "shake-x 1s ease", + "shake-y": "shake-y 1s ease", + "head-shake": "head-shake 1s ease-in-out", + swing: "swing 1s ease", + tada: "tada 1s ease", + wobble: "wobble 1s ease", + jello: "jello 1s ease", + "heart-beat": "heart-beat 1.3s ease-in-out infinite", + "back-in-down": "back-in-down 1s ease", + "back-in-left": "back-in-left 1s ease", + "back-in-right": "back-in-right 1s ease", + "back-in-up": "back-in-up 1s ease", + "back-out-down": "back-out-down 1s ease", + "back-out-left": "back-out-left 1s ease", + "back-out-right": "back-out-right 1s ease", + "back-out-up": "back-out-up 1s ease", + "bounce-in": "bounce-in 0.75s ease", + "bounce-in-down": "bounce-in-down 1s ease", + "bounce-in-left": "bounce-in-left 1s ease", + "bounce-in-right": "bounce-in-right 1s ease", + "bounce-in-up": "bounce-in-up 1s ease", + "bounce-out": "bounce-out 0.75s ease", + "bounce-out-down": "bounce-out-down 1s ease", + "bounce-out-left": "bounce-out-left 1s ease", + "bounce-out-right": "bounce-out-right 1s ease", + "bounce-out-up": "bounce-out-up 1s ease", + "fade-in": "fade-in 0.3s ease", + "fade-in-down": "fade-in-down 0.3s ease", + "fade-in-down-big": "fade-in-down-big 0.3s ease", + "fade-in-left": "fade-in-left 0.3s ease", + "fade-in-left-big": "fade-in-left-big 0.3s ease", + "fade-in-right": "fade-in-right 0.3s ease", + "fade-in-right-big": "fade-in-right-big 0.3s ease", + "fade-in-up": "fade-in-up 0.3s ease", + "fade-in-up-big": "fade-in-up-big 0.3s ease", + "fade-in-top-left": "fade-in-top-left 0.3s ease", + "fade-in-top-right": "fade-in-top-right 0.3s ease", + "fade-in-bottom-left": "fade-in-bottom-left 0.3s ease", + "fade-in-bottom-right": "fade-in-bottom-right 0.3s ease", + "fade-out": "fade-out 0.3s ease", + "fade-out-down": "fade-out-down 0.3s ease", + "fade-out-down-big": "fade-out-down-big 0.3s ease", + "fade-out-left": "fade-out-left 0.3s ease", + "fade-out-left-big": "fade-out-left-big 0.3s ease", + "fade-out-right": "fade-out-right 0.3s ease", + "fade-out-right-big": "fade-out-right-big 0.3s ease", + "fade-out-up": "fade-out-up 0.3s ease", + "fade-out-up-big": "fade-out-up-big 0.3s ease", + "fade-out-top-left": "fade-out-top-left 0.3s ease", + "fade-out-top-right": "fade-out-top-right 0.3s ease", + "fade-out-bottom-right": "fade-out-bottom-right 0.3s ease", + "fade-out-bottom-left": "fade-out-bottom-left 0.3s ease", + flip: "flip 1s ease", + "flip-in-x": "flip-in-x 0.75s ease", + "flip-in-y": "flip-in-y 0.75s ease", + "flip-out-x": "flip-out-x 0.75s ease", + "flip-out-y": "flip-out-y 0.75s ease", + "light-speed-in-right": "light-speed-in-right 1s ease-out", + "light-speed-in-left": "light-speed-in-left 1s ease-out", + "light-speed-out-right": "light-speed-out-right 1s ease-in", + "light-speed-out-left": "light-speed-out-left 1s ease-in", + "rotate-in": "rotate-in 0.6s ease", + "rotate-in-down-left": "rotate-in-down-left 0.6s ease", + "rotate-in-down-right": "rotate-in-down-right 0.6s ease", + "rotate-in-up-left": "rotate-in-up-left 0.6s ease", + "rotate-in-up-right": "rotate-in-up-right 0.6s ease", + "rotate-out": "rotate-out 0.6s ease", + "rotate-out-down-left": "rotate-out-down-left 0.6s ease", + "rotate-out-down-right": "rotate-out-down-right 0.6s ease", + "rotate-out-up-left": "rotate-out-up-left 0.6s ease", + "rotate-out-up-right": "rotate-out-up-right 0.6s ease", + hinge: "hinge 2s ease", + "jack-in-the-box": "jack-in-the-box 1s ease", + "roll-in": "roll-in 1s ease", + "roll-out": "roll-out 1s ease", + "zoom-in": "zoom-in 0.3s ease", + "zoom-in-down": "zoom-in-down 0.3s ease", + "zoom-in-left": "zoom-in-left 0.3s ease", + "zoom-in-right": "zoom-in-right 0.3s ease", + "zoom-in-up": "zoom-in-up 0.3s ease", + "zoom-out": "zoom-out 0.3s ease", + "zoom-out-down": "zoom-out-down 0.3s ease", + "zoom-out-left": "zoom-out-left 0.3s ease", + "zoom-out-right": "zoom-out-right 0.3s ease", + "zoom-out-up": "zoom-out-up 0.3s ease", + "slide-in-down": "slide-in-down 0.3s ease", + "slide-in-left": "slide-in-left 0.3s ease", + "slide-in-right": "slide-in-right 0.3s ease", + "slide-in-up": "slide-in-up 0.3s ease", + "slide-out-down": "slide-out-down 0.3s ease", + "slide-out-left": "slide-out-left 0.3s ease", + "slide-out-right": "slide-out-right 0.3s ease", + "slide-out-up": "slide-out-up 0.3s ease", + "content-show": "content-show 150ms cubic-bezier(0.16, 1, 0.3, 1)", +} diff --git a/packages/panda-animation/src/tokens/keyframes/index.ts b/packages/panda-animation/src/tokens/keyframes/index.ts deleted file mode 100644 index 4ae9ed21..00000000 --- a/packages/panda-animation/src/tokens/keyframes/index.ts +++ /dev/null @@ -1,923 +0,0 @@ -import { defineKeyframes } from "@pandacss/dev" - -export const keyframes = defineKeyframes({ - "accordion-down_radix": { - from: { height: 0 }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up_radix": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: 0 }, - }, - bounce: { - "0%, 20%, 53%, to": { - animationTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - transform: "translateZ(0)", - }, - "40%, 43%": { - animationTimingFunction: "cubic-bezier(0.755, 0.05, 0.855, 0.06)", - transform: "translate3d(0, -30px, 0) scaleY(1.1)", - }, - "70%": { - animationTimingFunction: "cubic-bezier(0.755, 0.05, 0.855, 0.06)", - transform: "translate3d(0, -15px, 0) scaleY(1.05)", - }, - "80%": { - transitionTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - transform: "translateZ(0) scaleY(0.95)", - }, - "90%": { - transform: "translate3d(0, -4px, 0) scaleY(1.02)", - }, - }, - flash: { - "0%, 50%, to": { opacity: 1 }, - "25%, 75%": { opacity: 0 }, - }, - pulse: { - "0%": { transform: "scaleX(1)" }, - "50%": { transform: "scale3d(1.05, 1.05, 1.05)" }, - to: { transform: "scaleX(1)" }, - }, - rubberBand: { - "0%": { transform: "scaleX(1)" }, - "30%": { transform: "scale3d(1.25, 0.75, 1)" }, - "40%": { transform: "scale3d(0.75, 1.25, 1)" }, - "50%": { transform: "scale3d(1.15, 0.85, 1)" }, - "65%": { transform: "scale3d(0.95, 1.05, 1)" }, - "75%": { transform: "scale3d(1.05, 0.95, 1)" }, - to: { transform: "scaleX(1)" }, - }, - shakeX: { - "0%, to": { transform: "translateZ(0)" }, - "10%, 30%, 50%, 70%, 90%": { transform: "translate3d(-10px, 0, 0)" }, - "20%, 40%, 60%, 80%": { transform: "translate3d(10px, 0, 0)" }, - }, - shakeY: { - "0%, to": { transform: "translateZ(0)" }, - "10%, 30%, 50%, 70%, 90%": { transform: "translate3d(0, -10px, 0)" }, - "20%, 40%, 60%, 80%": { transform: "translate3d(0, 10px, 0)" }, - }, - headShake: { - "0%": { transform: "translateX(0)" }, - "6.5%": { - transform: "translateX(-6px) rotateY(-9deg)", - }, - "18.5%": { transform: "translateX(5px) rotateY(7deg)" }, - "31.5%": { - transform: "translateX(-3px) rotateY(-5deg)", - }, - "43.5%": { transform: "translateX(2px) rotateY(3deg)" }, - "50%": { transform: "translateX(0)" }, - }, - swing: { - "20%": { transform: "rotate(15deg)" }, - "40%": { transform: "rotate(-10deg)" }, - "60%": { transform: "rotate(5deg)" }, - "80%": { transform: "rotate(-5deg)" }, - to: { transform: "rotate(0deg)" }, - }, - tada: { - "0%": { transform: "scaleX(1)" }, - "10%, 20%": { - transform: "scale3d(0.9, 0.9, 0.9) rotate(-3deg)", - }, - "30%, 50%, 70%, 90%": { - transform: "scale3d(1.1, 1.1, 1.1) rotate(3deg)", - }, - "40%, 60%, 80%": { - transform: "scale3d(1.1, 1.1, 1.1) rotate(-3deg)", - }, - to: { transform: "scaleX(1)" }, - }, - wobble: { - "0%": { transform: "translateZ(0)" }, - "15%": { - transform: "translate3d(-25%, 0, 0) rotate(-5deg)", - }, - "30%": { - transform: "translate3d(20%, 0, 0) rotate(3deg)", - }, - "45%": { - transform: "translate3d(-15%, 0, 0) rotate(-3deg)", - }, - "60%": { - transform: "translate3d(10%, 0, 0) rotate(2deg)", - }, - "75%": { - transform: "translate3d(-5%, 0, 0) rotate(-1deg)", - }, - to: { transform: "translateZ(0)" }, - }, - jello: { - "0%, 11.1%, to": { transform: "translateZ(0)" }, - "22.2%": { - transform: "skewX(-12.5deg) skewY(-12.5deg)", - }, - "33.3%": { transform: "skewX(6.25deg) skewY(6.25deg)" }, - "44.4%": { - transform: "skewX(-3.125deg) skewY(-3.125deg)", - }, - "55.5%": { - transform: "skewX(1.5625deg) skewY(1.5625deg)", - }, - "66.6%": { - transform: "skewX(-0.78125deg) skewY(-0.78125deg)", - }, - "77.7%": { - transform: "skewX(0.390625deg) skewY(0.390625deg)", - }, - "88.8%": { - transform: "skewX(-0.1953125deg) skewY(-0.1953125deg)", - }, - }, - heartBeat: { - "0%": { transform: "scale(1)" }, - "14%": { transform: "scale(1.3)" }, - "28%": { transform: "scale(1)" }, - "42%": { transform: "scale(1.3)" }, - "70%": { transform: "scale(1)" }, - }, - backInDown: { - "0%": { - transform: "translateY(-1200px) scale(0.7)", - opacity: 0.7, - }, - "80%": { - transform: "translateY(0) scale(0.7)", - opacity: 0.7, - }, - to: { transform: "scale(1)", opacity: 1 }, - }, - backInLeft: { - "0%": { - transform: "translateX(-2000px) scale(0.7)", - opacity: 0.7, - }, - "80%": { - transform: "translateX(0) scale(0.7)", - opacity: 0.7, - }, - to: { transform: "scale(1)", opacity: 1 }, - }, - backInRight: { - "0%": { - transform: "translateX(2000px) scale(0.7)", - opacity: 0.7, - }, - "80%": { - transform: "translateX(0) scale(0.7)", - opacity: 0.7, - }, - to: { transform: "scale(1)", opacity: 1 }, - }, - backInUp: { - "0%": { - transform: "translateY(1200px) scale(0.7)", - opacity: 0.7, - }, - "80%": { - transform: "translateY(0) scale(0.7)", - opacity: 0.7, - }, - to: { transform: "scale(1)", opacity: 1 }, - }, - backOutDown: { - "0%": { transform: "scale(1)", opacity: 1 }, - "20%": { - transform: "translateY(0) scale(0.7)", - opacity: 0.7, - }, - to: { - transform: "translateY(700px) scale(0.7)", - opacity: 0.7, - }, - }, - backOutLeft: { - "0%": { transform: "scale(1)", opacity: 1 }, - "20%": { - transform: "translateX(0) scale(0.7)", - opacity: 0.7, - }, - to: { - transform: "translateX(-2000px) scale(0.7)", - opacity: 0.7, - }, - }, - backOutRight: { - "0%": { transform: "scale(1)", opacity: 1 }, - "20%": { - transform: "translateX(0) scale(0.7)", - opacity: 0.7, - }, - to: { - transform: "translateX(2000px) scale(0.7)", - opacity: 0.7, - }, - }, - backOutUp: { - "0%": { transform: "scale(1)", opacity: 1 }, - "20%": { - transform: "translateY(0) scale(0.7)", - opacity: 0.7, - }, - to: { - transform: "translateY(-700px) scale(0.7)", - opacity: 0.7, - }, - }, - bounceIn: { - "0%, 20%, 40%, 60%, 80%, to": { - animationTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - }, - "0%": { - opacity: 0, - transform: "scale3d(0.3, 0.3, 0.3)", - }, - "20%": { transform: "scale3d(1.1, 1.1, 1.1)" }, - "40%": { transform: "scale3d(0.9, 0.9, 0.9)" }, - "60%": { - opacity: 1, - transform: "scale3d(1.03, 1.03, 1.03)", - }, - "80%": { transform: "scale3d(0.97, 0.97, 0.97)" }, - to: { opacity: 1, transform: "scaleX(1)" }, - }, - bounceInDown: { - "0%, 60%, 75%, 90%, to": { - animationTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - }, - "0%": { - opacity: 0, - transform: "translate3d(0, -3000px, 0) scaleY(3)", - }, - "60%": { - opacity: 1, - transform: "translate3d(0, 25px, 0) scaleY(0.9)", - }, - "75%": { - transform: "translate3d(0, -10px, 0) scaleY(0.95)", - }, - "90%": { - transform: "translate3d(0, 5px, 0) scaleY(0.985)", - }, - to: { transform: "translateZ(0)" }, - }, - bounceInLeft: { - "0%, 60%, 75%, 90%, to": { - animationTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - }, - "0%": { - opacity: 0, - transform: "translate3d(-3000px, 0, 0) scaleX(3)", - }, - "60%": { - opacity: 1, - transform: "translate3d(25px, 0, 0) scaleX(1)", - }, - "75%": { - transform: "translate3d(-10px, 0, 0) scaleX(0.98)", - }, - "90%": { - transform: "translate3d(5px, 0, 0) scaleX(0.995)", - }, - to: { transform: "translateZ(0)" }, - }, - bounceInRight: { - "0%, 60%, 75%, 90%, to": { - animationTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - }, - "0%": { - opacity: 0, - transform: "translate3d(3000px, 0, 0) scaleX(3)", - }, - "60%": { - opacity: 1, - transform: "translate3d(-25px, 0, 0) scaleX(1)", - }, - "75%": { - transform: "translate3d(10px, 0, 0) scaleX(0.98)", - }, - "90%": { - transform: "translate3d(-5px, 0, 0) scaleX(0.995)", - }, - to: { transform: "translateZ(0)" }, - }, - bounceInUp: { - "0%, 60%, 75%, 90%, to": { - animationTimingFunction: "cubic-bezier(0.215, 0.61, 0.355, 1)", - }, - "0%": { - opacity: 0, - transform: "translate3d(0, 3000px, 0) scaleY(5)", - }, - "60%": { - opacity: 1, - transform: "translate3d(0, -20px, 0) scaleY(0.9)", - }, - "75%": { - transform: "translate3d(0, 10px, 0) scaleY(0.95)", - }, - "90%": { - transform: "translate3d(0, -5px, 0) scaleY(0.985)", - }, - to: { transform: "translateZ(0)" }, - }, - bounceOut: { - "20%": { transform: "scale3d(0.9, 0.9, 0.9)" }, - "50%, 55%": { - opacity: 1, - transform: "scale3d(1.1, 1.1, 1.1)", - }, - to: { - opacity: 0, - transform: "scale3d(0.3, 0.3, 0.3)", - }, - }, - bounceOutDown: { - "20%": { - transform: "translate3d(0, 10px, 0) scaleY(0.985)", - }, - "40%, 45%": { - opacity: 1, - transform: "translate3d(0, -20px, 0) scaleY(0.9)", - }, - to: { - opacity: 0, - transform: "translate3d(0, 2000px, 0) scaleY(3)", - }, - }, - bounceOutLeft: { - "20%": { - opacity: 1, - transform: "translate3d(20px, 0, 0) scaleX(0.9)", - }, - to: { - opacity: 0, - transform: "translate3d(-2000px, 0, 0) scaleX(2)", - }, - }, - bounceOutRight: { - "20%": { - opacity: 1, - transform: "translate3d(-20px, 0, 0) scaleX(0.9)", - }, - to: { - opacity: 0, - transform: "translate3d(2000px, 0, 0) scaleX(2)", - }, - }, - bounceOutUp: { - "20%": { - transform: "translate3d(0, -10px, 0) scaleY(0.985)", - }, - "40%, 45%": { - opacity: 1, - transform: "translate3d(0, 20px, 0) scaleY(0.9)", - }, - to: { - opacity: 0, - transform: "translate3d(0, -2000px, 0) scaleY(3)", - }, - }, - fadeIn: { "0%": { opacity: 0 }, to: { opacity: 1 } }, - fadeInDown: { - "0%": { - opacity: 0, - transform: "translate3d(0, -100%, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInDownBig: { - "0%": { - opacity: 0, - transform: "translate3d(0, -2000px, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInLeft: { - "0%": { - opacity: 0, - transform: "translate3d(-100%, 0, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInLeftBig: { - "0%": { - opacity: 0, - transform: "translate3d(-2000px, 0, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInRight: { - "0%": { - opacity: 0, - transform: "translate3d(100%, 0, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInRightBig: { - "0%": { - opacity: 0, - transform: "translate3d(2000px, 0, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInUp: { - "0%": { - opacity: 0, - transform: "translate3d(0, 100%, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInUpBig: { - "0%": { - opacity: 0, - transform: "translate3d(0, 2000px, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInTopLeft: { - "0%": { - opacity: 0, - transform: "translate3d(-100%, -100%, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInTopRight: { - "0%": { - opacity: 0, - transform: "translate3d(100%, -100%, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInBottomLeft: { - "0%": { - opacity: 0, - transform: "translate3d(-100%, 100%, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeInBottomRight: { - "0%": { - opacity: 0, - transform: "translate3d(100%, 100%, 0)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - fadeOut: { "0%": { opacity: 1 }, to: { opacity: 0 } }, - fadeOutDown: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(0, 100%, 0)", - }, - }, - fadeOutDownBig: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(0, 2000px, 0)", - }, - }, - fadeOutLeft: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(-100%, 0, 0)", - }, - }, - fadeOutLeftBig: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(-2000px, 0, 0)", - }, - }, - fadeOutRight: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(100%, 0, 0)", - }, - }, - fadeOutRightBig: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(2000px, 0, 0)", - }, - }, - fadeOutUp: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(0, -100%, 0)", - }, - }, - fadeOutUpBig: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(0, -2000px, 0)", - }, - }, - fadeOutTopLeft: { - "0%": { opacity: 1, transform: "translateZ(0)" }, - to: { - opacity: 0, - transform: "translate3d(-100%, -100%, 0)", - }, - }, - fadeOutTopRight: { - "0%": { opacity: 1, transform: "translateZ(0)" }, - to: { - opacity: 0, - transform: "translate3d(100%, -100%, 0)", - }, - }, - fadeOutBottomRight: { - "0%": { opacity: 1, transform: "translateZ(0)" }, - to: { - opacity: 0, - transform: "translate3d(100%, 100%, 0)", - }, - }, - fadeOutBottomLeft: { - "0%": { opacity: 1, transform: "translateZ(0)" }, - to: { - opacity: 0, - transform: "translate3d(-100%, 100%, 0)", - }, - }, - flip: { - "0%": { - transform: "perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn)", - animationTimingFunction: "ease-out", - }, - "40%": { - transform: - "perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg)", - animationTimingFunction: "ease-out", - }, - "50%": { - transform: - "perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg)", - animationTimingFunction: "ease-in", - }, - "80%": { - transform: - "perspective(400px) scale3d(0.95, 0.95, 0.95) translateZ(0) rotateY(0deg)", - animationTimingFunction: "ease-in", - }, - to: { - transform: "perspective(400px) scaleX(1) translateZ(0) rotateY(0deg)", - animationTimingFunction: "ease-in", - }, - }, - flipInX: { - "0%": { - transform: "perspective(400px) rotateX(90deg)", - animationTimingFunction: "ease-in", - opacity: 0, - }, - "40%": { - transform: "perspective(400px) rotateX(-20deg)", - animationTimingFunction: "ease-in", - }, - "60%": { - transform: "perspective(400px) rotateX(10deg)", - opacity: 1, - }, - "80%": { - transform: "perspective(400px) rotateX(-5deg)", - }, - to: { transform: "perspective(400px)" }, - }, - flipInY: { - "0%": { - transform: "perspective(400px) rotateY(90deg)", - animationTimingFunction: "ease-in", - opacity: 0, - }, - "40%": { - transform: "perspective(400px) rotateY(-20deg)", - animationTimingFunction: "ease-in", - }, - "60%": { - transform: "perspective(400px) rotateY(10deg)", - opacity: 1, - }, - "80%": { - transform: "perspective(400px) rotateY(-5deg)", - }, - to: { transform: "perspective(400px)" }, - }, - flipOutX: { - "0%": { transform: "perspective(400px)" }, - "30%": { - transform: "perspective(400px) rotateX(-20deg)", - opacity: 1, - }, - to: { - transform: "perspective(400px) rotateX(90deg)", - opacity: 0, - }, - }, - flipOutY: { - "0%": { transform: "perspective(400px)" }, - "30%": { - transform: "perspective(400px) rotateY(-15deg)", - opacity: 1, - }, - to: { - transform: "perspective(400px) rotateY(90deg)", - opacity: 0, - }, - }, - lightSpeedInRight: { - "0%": { - transform: "translate3d(100%, 0, 0) skewX(-30deg)", - opacity: 0, - }, - "60%": { transform: "skewX(20deg)", opacity: 1 }, - "80%": { transform: "skewX(-5deg)" }, - to: { transform: "translateZ(0)" }, - }, - lightSpeedInLeft: { - "0%": { - transform: "translate3d(-100%, 0, 0) skewX(30deg)", - opacity: 0, - }, - "60%": { transform: "skewX(-20deg)", opacity: 1 }, - "80%": { transform: "skewX(5deg)" }, - to: { transform: "translateZ(0)" }, - }, - lightSpeedOutRight: { - "0%": { opacity: 1 }, - to: { - transform: "translate3d(100%, 0, 0) skewX(30deg)", - opacity: 0, - }, - }, - lightSpeedOutLeft: { - "0%": { opacity: 1 }, - to: { - transform: "translate3d(-100%, 0, 0) skewX(-30deg)", - opacity: 0, - }, - }, - rotateIn: { - "0%": { transform: "rotate(-200deg)", opacity: 0 }, - to: { transform: "translateZ(0)", opacity: 1 }, - }, - rotateInDownLeft: { - "0%": { transform: "rotate(-45deg)", opacity: 0 }, - to: { transform: "translateZ(0)", opacity: 1 }, - }, - rotateInDownRight: { - "0%": { transform: "rotate(45deg)", opacity: 0 }, - to: { transform: "translateZ(0)", opacity: 1 }, - }, - rotateInUpLeft: { - "0%": { transform: "rotate(45deg)", opacity: 0 }, - to: { transform: "translateZ(0)", opacity: 1 }, - }, - rotateInUpRight: { - "0%": { transform: "rotate(-90deg)", opacity: 0 }, - to: { transform: "translateZ(0)", opacity: 1 }, - }, - rotateOut: { - "0%": { opacity: 1 }, - to: { transform: "rotate(200deg)", opacity: 0 }, - }, - rotateOutDownLeft: { - "0%": { opacity: 1 }, - to: { transform: "rotate(45deg)", opacity: 0 }, - }, - rotateOutDownRight: { - "0%": { opacity: 1 }, - to: { transform: "rotate(-45deg)", opacity: 0 }, - }, - rotateOutUpLeft: { - "0%": { opacity: 1 }, - to: { transform: "rotate(-45deg)", opacity: 0 }, - }, - rotateOutUpRight: { - "0%": { opacity: 1 }, - to: { transform: "rotate(90deg)", opacity: 0 }, - }, - hinge: { - "0%": { animationTimingFunction: "ease-in-out" }, - "20%, 60%": { - transform: "rotate(80deg)", - animationTimingFunction: "ease-in-out", - }, - "40%, 80%": { - transform: "rotate(60deg)", - animationTimingFunction: "ease-in-out", - opacity: 1, - }, - to: { - transform: "translate3d(0, 700px, 0)", - opacity: 0, - }, - }, - jackInTheBox: { - "0%": { - opacity: 0, - transform: "scale(0.1) rotate(30deg)", - transformOrigin: "center bottom", - }, - "50%": { transform: "rotate(-10deg)" }, - "70%": { transform: "rotate(3deg)" }, - to: { opacity: 1, transform: "scale(1)" }, - }, - rollIn: { - "0%": { - opacity: 0, - transform: "translate3d(-100%, 0, 0) rotate(-120deg)", - }, - to: { opacity: 1, transform: "translateZ(0)" }, - }, - rollOut: { - "0%": { opacity: 1 }, - to: { - opacity: 0, - transform: "translate3d(100%, 0, 0) rotate(120deg)", - }, - }, - zoomIn: { - "0%": { - opacity: 0, - transform: "scale3d(0.3, 0.3, 0.3)", - }, - "50%": { opacity: 1 }, - }, - zoomInDown: { - "0%": { - opacity: 0, - transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, -1000px, 0)", - animationTimingFunction: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - }, - "60%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0)", - animationTimingFunction: "cubic-bezier(0.175, 0.885, 0.32, 1)", - }, - }, - zoomInLeft: { - "0%": { - opacity: 0, - transform: "scale3d(0.1, 0.1, 0.1) translate3d(-1000px, 0, 0)", - animationTimingFunction: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - }, - "60%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(10px, 0, 0)", - animationTimingFunction: "cubic-bezier(0.175, 0.885, 0.32, 1)", - }, - }, - zoomInRight: { - "0%": { - opacity: 0, - transform: "scale3d(0.1, 0.1, 0.1) translate3d(1000px, 0, 0)", - animationTimingFunction: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - }, - "60%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(-10px, 0, 0)", - animationTimingFunction: "cubic-bezier(0.175, 0.885, 0.32, 1)", - }, - }, - zoomInUp: { - "0%": { - opacity: 0, - transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, 1000px, 0)", - animationTimingFunction: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - }, - "60%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0)", - animationTimingFunction: "cubic-bezier(0.175, 0.885, 0.32, 1)", - }, - }, - zoomOut: { - "0%": { opacity: 1 }, - "50%": { - opacity: 0, - transform: "scale3d(0.3, 0.3, 0.3)", - }, - to: { opacity: 0 }, - }, - zoomOutDown: { - "40%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0)", - animationTimingFunction: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - }, - to: { - opacity: 0, - transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, 2000px, 0)", - animationTimingFunction: "cubic-bezier(0.175, 0.885, 0.32, 1)", - }, - }, - zoomOutLeft: { - "40%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(42px, 0, 0)", - }, - to: { - opacity: 0, - transform: "scale(0.1) translate3d(-2000px, 0, 0)", - }, - }, - zoomOutRight: { - "40%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(-42px, 0, 0)", - }, - to: { - opacity: 0, - transform: "scale(0.1) translate3d(2000px, 0, 0)", - }, - }, - zoomOutUp: { - "40%": { - opacity: 1, - transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0)", - animationTimingFunction: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - }, - to: { - opacity: 0, - transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, -2000px, 0)", - animationTimingFunction: "cubic-bezier(0.175, 0.885, 0.32, 1)", - }, - }, - slideInDown: { - "0%": { - transform: "translate3d(0, -100%, 0)", - visibility: "visible", - }, - to: { transform: "translateZ(0)" }, - }, - slideInLeft: { - "0%": { - transform: "translate3d(-100%, 0, 0)", - visibility: "visible", - }, - to: { transform: "translateZ(0)" }, - }, - slideInRight: { - "0%": { - transform: "translate3d(100%, 0, 0)", - visibility: "visible", - }, - to: { transform: "translateZ(0)" }, - }, - slideInUp: { - "0%": { - transform: "translate3d(0, 100%, 0)", - visibility: "visible", - }, - to: { transform: "translateZ(0)" }, - }, - slideOutDown: { - "0%": { transform: "translateZ(0)" }, - to: { - visibility: "hidden", - transform: "translate3d(0, 100%, 0)", - }, - }, - slideOutLeft: { - "0%": { transform: "translateZ(0)" }, - to: { - visibility: "hidden", - transform: "translate3d(-100%, 0, 0)", - }, - }, - slideOutRight: { - "0%": { transform: "translateZ(0)" }, - to: { - visibility: "hidden", - transform: "translate3d(100%, 0, 0)", - }, - }, - slideOutUp: { - "0%": { transform: "translateZ(0)" }, - to: { - visibility: "hidden", - transform: "translate3d(0, -100%, 0)", - }, - }, - contentShow: { - "0%": { - opacity: 0, - transform: "translate(-50%, -48%) scale(0.96)", - }, - to: { - opacity: 1, - transform: "translate(-50%, -50%) scale(1)", - }, - }, -}) diff --git a/packages/panda-animation/tsup.config.ts b/packages/panda-animation/tsup.config.ts deleted file mode 100644 index a06f9434..00000000 --- a/packages/panda-animation/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup" - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["cjs", "esm"], - clean: true, - sourcemap: false, - minify: true, - dts: true, - external: ["@pandacss/dev"], -}) From eef8eed79f9cb397ccc7eeea9d7713694cad815e Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Mon, 9 Feb 2026 14:29:57 +0900 Subject: [PATCH 06/22] feat(theme-plugin): add Tailwind v4 theme plugin with semantic color system --- packages/theme-plugin/package.json | 22 +++++ packages/theme-plugin/src/index.test.ts | 59 +++++++++++++ packages/theme-plugin/src/index.ts | 96 +++++++++++++++++++++ packages/theme-plugin/src/options.ts | 29 +++++++ packages/theme-plugin/src/themes/default.ts | 67 ++++++++++++++ packages/theme-plugin/src/themes/forest.ts | 67 ++++++++++++++ packages/theme-plugin/src/themes/index.ts | 10 +++ packages/theme-plugin/src/themes/ocean.ts | 67 ++++++++++++++ packages/theme-plugin/src/types.ts | 42 +++++++++ packages/theme-plugin/tsconfig.json | 7 ++ 10 files changed, 466 insertions(+) create mode 100644 packages/theme-plugin/package.json create mode 100644 packages/theme-plugin/src/index.test.ts create mode 100644 packages/theme-plugin/src/index.ts create mode 100644 packages/theme-plugin/src/options.ts create mode 100644 packages/theme-plugin/src/themes/default.ts create mode 100644 packages/theme-plugin/src/themes/forest.ts create mode 100644 packages/theme-plugin/src/themes/index.ts create mode 100644 packages/theme-plugin/src/themes/ocean.ts create mode 100644 packages/theme-plugin/src/types.ts create mode 100644 packages/theme-plugin/tsconfig.json diff --git a/packages/theme-plugin/package.json b/packages/theme-plugin/package.json new file mode 100644 index 00000000..e24a0060 --- /dev/null +++ b/packages/theme-plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "@jongh/theme-plugin", + "version": "0.1.0", + "type": "module", + "description": "Tailwind v4 theme plugin with light/dark support", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint --max-warnings 0 --no-warn-ignored --fix", + "check-type": "tsgo --noEmit", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "@jongh/eslint": "workspace:*", + "@jongh/tsconfig": "workspace:*" + }, + "dependencies": { + "tailwindcss": "^4.1.12" + } +} diff --git a/packages/theme-plugin/src/index.test.ts b/packages/theme-plugin/src/index.test.ts new file mode 100644 index 00000000..bf492c5f --- /dev/null +++ b/packages/theme-plugin/src/index.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs" +import { compile } from "tailwindcss" +import { describe, expect, it } from "vitest" + +import plugin from "./index" + +const tailwindBase = readFileSync( + require.resolve("tailwindcss/index.css"), + "utf-8", +) + +async function buildCss(candidates: string[]) { + const compiler = await compile( + `@import "tailwindcss"; @plugin "./theme-plugin";`, + { + loadModule: async (_id, base) => ({ + path: "./theme-plugin", + base, + module: plugin({ themes: "default --default" }), + }), + loadStylesheet: async (id, base) => { + if (id === "tailwindcss") { + return { path: "tailwindcss/index.css", base, content: tailwindBase } + } + throw new Error(`Unknown stylesheet: ${id}`) + }, + }, + ) + return compiler.build(candidates) +} + +describe("테마 플러그인 CSS 생성 검증", () => { + it("bg-primary 클래스가 background-color: var(--color-primary) CSS를 생성한다", async () => { + const css = await buildCss(["bg-primary"]) + expect(css).toContain("background-color: var(--color-primary)") + }) + + it("text-base-content 클래스가 color: var(--color-base-content) CSS를 생성한다", async () => { + const css = await buildCss(["text-base-content"]) + expect(css).toContain("color: var(--color-base-content)") + }) + + it("rounded-selector 클래스가 border-radius: var(--radius-selector) CSS를 생성한다", async () => { + const css = await buildCss(["rounded-selector"]) + expect(css).toContain("border-radius: var(--radius-selector)") + }) + + it("기본 테마의 CSS 변수(--color-primary, --color-base-100, --radius-selector)가 출력에 포함된다", async () => { + const css = await buildCss([]) + expect(css).toContain("--color-primary:") + expect(css).toContain("--color-base-100:") + expect(css).toContain("--radius-selector:") + }) + + it("dark mode media query(prefers-color-scheme: dark)가 출력에 포함된다", async () => { + const css = await buildCss([]) + expect(css).toContain("prefers-color-scheme: dark") + }) +}) diff --git a/packages/theme-plugin/src/index.ts b/packages/theme-plugin/src/index.ts new file mode 100644 index 00000000..03778030 --- /dev/null +++ b/packages/theme-plugin/src/index.ts @@ -0,0 +1,96 @@ +import plugin from "tailwindcss/plugin" + +import { parseThemeSelection } from "./options" +import { builtInThemeMap, builtInThemes } from "./themes" +import type { Theme, ThemeTokens } from "./types" + +const colorVariables: Record = { + "base-100": "var(--color-base-100)", + "base-200": "var(--color-base-200)", + "base-300": "var(--color-base-300)", + "base-content": "var(--color-base-content)", + primary: "var(--color-primary)", + "primary-content": "var(--color-primary-content)", + secondary: "var(--color-secondary)", + "secondary-content": "var(--color-secondary-content)", + accent: "var(--color-accent)", + "accent-content": "var(--color-accent-content)", + neutral: "var(--color-neutral)", + "neutral-content": "var(--color-neutral-content)", + info: "var(--color-info)", + "info-content": "var(--color-info-content)", + success: "var(--color-success)", + "success-content": "var(--color-success-content)", + warning: "var(--color-warning)", + "warning-content": "var(--color-warning-content)", + error: "var(--color-error)", + "error-content": "var(--color-error-content)", +} + +const radiusVariables: Record = { + selector: "var(--radius-selector)", + field: "var(--radius-field)", + box: "var(--radius-box)", +} + +function tokensToVars(tokens: ThemeTokens): Record { + return tokens as unknown as Record +} + +export default plugin.withOptions( + (options?: unknown) => { + const resolvedOptions = + options && typeof options === "object" + ? (options as { themes?: string | string[] | boolean }) + : {} + + return ({ addBase }) => { + const selection = parseThemeSelection(resolvedOptions.themes) + + for (const entry of selection) { + let themes: Theme[] + + if (entry.name === "all") { + themes = builtInThemes + } else { + const theme = builtInThemeMap[entry.name] + if (!theme) { + throw new Error( + `Unknown theme '${entry.name}'. Available: ${builtInThemes.map((t) => t.name).join(", ")}`, + ) + } + themes = [theme] + } + + for (const theme of themes) { + const isDefault = + entry.isDefault || + (entry.name === "all" && theme === builtInThemes[0]) + + let selector = `[data-theme="${theme.name}"]` + if (isDefault) { + selector = `:where(:root), ${selector}` + } + + // light tokens + addBase({ [selector]: tokensToVars(theme.light) }) + + // dark tokens via prefers-color-scheme + addBase({ + "@media (prefers-color-scheme: dark)": { + [selector]: tokensToVars(theme.dark), + }, + }) + } + } + } + }, + () => ({ + theme: { + extend: { + colors: colorVariables, + borderRadius: radiusVariables, + }, + }, + }), +) diff --git a/packages/theme-plugin/src/options.ts b/packages/theme-plugin/src/options.ts new file mode 100644 index 00000000..081d8a14 --- /dev/null +++ b/packages/theme-plugin/src/options.ts @@ -0,0 +1,29 @@ +import type { ThemeSelection } from "./types" + +export function parseThemeSelection(themes?: unknown): ThemeSelection[] { + if (themes === false || themes === "false") { + return [] + } + + if (!themes || themes === true) { + return [{ name: "default", isDefault: true }] + } + + const source = Array.isArray(themes) ? themes.join(",") : String(themes) + + if (source.trim().toLowerCase() === "all") { + return [{ name: "all", isDefault: false }] + } + + return source + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [name, ...flags] = entry.split(/\s+/) + return { + name, + isDefault: flags.includes("--default"), + } + }) +} diff --git a/packages/theme-plugin/src/themes/default.ts b/packages/theme-plugin/src/themes/default.ts new file mode 100644 index 00000000..17a45608 --- /dev/null +++ b/packages/theme-plugin/src/themes/default.ts @@ -0,0 +1,67 @@ +import type { Theme } from "../types" + +export const defaultTheme: Theme = { + name: "default", + light: { + "color-scheme": "light", + "--color-base-100": "oklch(100% 0 0)", + "--color-base-200": "oklch(98% 0 0)", + "--color-base-300": "oklch(95% 0 0)", + "--color-base-content": "oklch(21% 0.006 285.885)", + "--color-primary": "oklch(45% 0.24 277.023)", + "--color-primary-content": "oklch(93% 0.034 272.788)", + "--color-secondary": "oklch(65% 0.241 354.308)", + "--color-secondary-content": "oklch(94% 0.028 342.258)", + "--color-accent": "oklch(77% 0.152 181.912)", + "--color-accent-content": "oklch(38% 0.063 188.416)", + "--color-neutral": "oklch(14% 0.005 285.823)", + "--color-neutral-content": "oklch(92% 0.004 286.32)", + "--color-info": "oklch(74% 0.16 232.661)", + "--color-info-content": "oklch(29% 0.066 243.157)", + "--color-success": "oklch(76% 0.177 163.223)", + "--color-success-content": "oklch(37% 0.077 168.94)", + "--color-warning": "oklch(82% 0.189 84.429)", + "--color-warning-content": "oklch(41% 0.112 45.904)", + "--color-error": "oklch(71% 0.194 13.428)", + "--color-error-content": "oklch(27% 0.105 12.094)", + "--radius-selector": "0.5rem", + "--radius-field": "0.25rem", + "--radius-box": "0.5rem", + "--size-selector": "0.25rem", + "--size-field": "0.25rem", + "--border": "1px", + "--depth": "1", + "--noise": "0", + }, + dark: { + "color-scheme": "dark", + "--color-base-100": "oklch(25.33% 0.016 252.42)", + "--color-base-200": "oklch(23.26% 0.014 253.1)", + "--color-base-300": "oklch(21.15% 0.012 254.09)", + "--color-base-content": "oklch(97.807% 0.029 256.847)", + "--color-primary": "oklch(58% 0.233 277.117)", + "--color-primary-content": "oklch(96% 0.018 272.314)", + "--color-secondary": "oklch(65% 0.241 354.308)", + "--color-secondary-content": "oklch(94% 0.028 342.258)", + "--color-accent": "oklch(77% 0.152 181.912)", + "--color-accent-content": "oklch(38% 0.063 188.416)", + "--color-neutral": "oklch(14% 0.005 285.823)", + "--color-neutral-content": "oklch(92% 0.004 286.32)", + "--color-info": "oklch(74% 0.16 232.661)", + "--color-info-content": "oklch(29% 0.066 243.157)", + "--color-success": "oklch(76% 0.177 163.223)", + "--color-success-content": "oklch(37% 0.077 168.94)", + "--color-warning": "oklch(82% 0.189 84.429)", + "--color-warning-content": "oklch(41% 0.112 45.904)", + "--color-error": "oklch(71% 0.194 13.428)", + "--color-error-content": "oklch(27% 0.105 12.094)", + "--radius-selector": "0.5rem", + "--radius-field": "0.25rem", + "--radius-box": "0.5rem", + "--size-selector": "0.25rem", + "--size-field": "0.25rem", + "--border": "1px", + "--depth": "1", + "--noise": "0", + }, +} diff --git a/packages/theme-plugin/src/themes/forest.ts b/packages/theme-plugin/src/themes/forest.ts new file mode 100644 index 00000000..189c3081 --- /dev/null +++ b/packages/theme-plugin/src/themes/forest.ts @@ -0,0 +1,67 @@ +import type { Theme } from "../types" + +export const forestTheme: Theme = { + name: "forest", + light: { + "color-scheme": "light", + "--color-base-100": "oklch(98.71% 0.02 123.72)", + "--color-base-200": "oklch(91.8% 0.018 123.72)", + "--color-base-300": "oklch(84.89% 0.017 123.72)", + "--color-base-content": "oklch(19.742% 0.004 123.72)", + "--color-primary": "oklch(58.92% 0.199 134.6)", + "--color-primary-content": "oklch(11.784% 0.039 134.6)", + "--color-secondary": "oklch(77.75% 0.196 111.09)", + "--color-secondary-content": "oklch(15.55% 0.039 111.09)", + "--color-accent": "oklch(85.39% 0.201 100.73)", + "--color-accent-content": "oklch(17.078% 0.04 100.73)", + "--color-neutral": "oklch(30.98% 0.075 108.6)", + "--color-neutral-content": "oklch(86.196% 0.015 108.6)", + "--color-info": "oklch(74% 0.16 232.661)", + "--color-info-content": "oklch(29% 0.066 243.157)", + "--color-success": "oklch(76% 0.177 163.223)", + "--color-success-content": "oklch(37% 0.077 168.94)", + "--color-warning": "oklch(82% 0.189 84.429)", + "--color-warning-content": "oklch(41% 0.112 45.904)", + "--color-error": "oklch(71% 0.194 13.428)", + "--color-error-content": "oklch(27% 0.105 12.094)", + "--radius-selector": "1rem", + "--radius-field": "0.5rem", + "--radius-box": "1rem", + "--size-selector": "0.25rem", + "--size-field": "0.25rem", + "--border": "1px", + "--depth": "0", + "--noise": "0", + }, + dark: { + "color-scheme": "dark", + "--color-base-100": "oklch(20.84% 0.008 17.911)", + "--color-base-200": "oklch(18.522% 0.007 17.911)", + "--color-base-300": "oklch(16.203% 0.007 17.911)", + "--color-base-content": "oklch(83.768% 0.001 17.911)", + "--color-primary": "oklch(68.628% 0.185 148.958)", + "--color-primary-content": "oklch(13.725% 0.037 148.958)", + "--color-secondary": "oklch(69.776% 0.135 168.327)", + "--color-secondary-content": "oklch(13.955% 0.027 168.327)", + "--color-accent": "oklch(70.628% 0.119 185.713)", + "--color-accent-content": "oklch(14.125% 0.023 185.713)", + "--color-neutral": "oklch(30.698% 0.039 171.364)", + "--color-neutral-content": "oklch(86.139% 0.007 171.364)", + "--color-info": "oklch(72.06% 0.191 231.6)", + "--color-info-content": "oklch(29% 0.066 243.157)", + "--color-success": "oklch(64.8% 0.15 160)", + "--color-success-content": "oklch(12.96% 0.03 160)", + "--color-warning": "oklch(84.71% 0.199 83.87)", + "--color-warning-content": "oklch(16.942% 0.039 83.87)", + "--color-error": "oklch(71.76% 0.221 22.18)", + "--color-error-content": "oklch(14.352% 0.044 22.18)", + "--radius-selector": "1rem", + "--radius-field": "0.5rem", + "--radius-box": "1rem", + "--size-selector": "0.25rem", + "--size-field": "0.25rem", + "--border": "1px", + "--depth": "0", + "--noise": "0", + }, +} diff --git a/packages/theme-plugin/src/themes/index.ts b/packages/theme-plugin/src/themes/index.ts new file mode 100644 index 00000000..bffd7e83 --- /dev/null +++ b/packages/theme-plugin/src/themes/index.ts @@ -0,0 +1,10 @@ +import type { Theme } from "../types" +import { defaultTheme } from "./default" +import { forestTheme } from "./forest" +import { oceanTheme } from "./ocean" + +export const builtInThemes: Theme[] = [defaultTheme, oceanTheme, forestTheme] + +export const builtInThemeMap: Record = Object.fromEntries( + builtInThemes.map((theme) => [theme.name, theme]), +) diff --git a/packages/theme-plugin/src/themes/ocean.ts b/packages/theme-plugin/src/themes/ocean.ts new file mode 100644 index 00000000..fde049ab --- /dev/null +++ b/packages/theme-plugin/src/themes/ocean.ts @@ -0,0 +1,67 @@ +import type { Theme } from "../types" + +export const oceanTheme: Theme = { + name: "ocean", + light: { + "color-scheme": "light", + "--color-base-100": "oklch(97.466% 0.011 259.822)", + "--color-base-200": "oklch(93.268% 0.016 262.751)", + "--color-base-300": "oklch(89.925% 0.016 262.749)", + "--color-base-content": "oklch(32.437% 0.022 264.182)", + "--color-primary": "oklch(56.86% 0.255 257.57)", + "--color-primary-content": "oklch(91.372% 0.051 257.57)", + "--color-secondary": "oklch(59.435% 0.077 254.027)", + "--color-secondary-content": "oklch(11.887% 0.015 254.027)", + "--color-accent": "oklch(77.464% 0.062 217.469)", + "--color-accent-content": "oklch(15.492% 0.012 217.469)", + "--color-neutral": "oklch(19.616% 0.063 257.651)", + "--color-neutral-content": "oklch(83.923% 0.012 257.651)", + "--color-info": "oklch(72.06% 0.191 231.6)", + "--color-info-content": "oklch(29% 0.066 243.157)", + "--color-success": "oklch(76% 0.177 163.223)", + "--color-success-content": "oklch(37% 0.077 168.94)", + "--color-warning": "oklch(82% 0.189 84.429)", + "--color-warning-content": "oklch(41% 0.112 45.904)", + "--color-error": "oklch(71% 0.194 13.428)", + "--color-error-content": "oklch(27% 0.105 12.094)", + "--radius-selector": "1rem", + "--radius-field": "0.5rem", + "--radius-box": "1rem", + "--size-selector": "0.25rem", + "--size-field": "0.25rem", + "--border": "1px", + "--depth": "0", + "--noise": "0", + }, + dark: { + "color-scheme": "dark", + "--color-base-100": "oklch(20.768% 0.039 265.754)", + "--color-base-200": "oklch(19.314% 0.037 265.754)", + "--color-base-300": "oklch(17.86% 0.034 265.754)", + "--color-base-content": "oklch(84.153% 0.007 265.754)", + "--color-primary": "oklch(75.351% 0.138 232.661)", + "--color-primary-content": "oklch(15.07% 0.027 232.661)", + "--color-secondary": "oklch(68.011% 0.158 276.934)", + "--color-secondary-content": "oklch(13.602% 0.031 276.934)", + "--color-accent": "oklch(72.36% 0.176 350.048)", + "--color-accent-content": "oklch(14.472% 0.035 350.048)", + "--color-neutral": "oklch(27.949% 0.036 260.03)", + "--color-neutral-content": "oklch(85.589% 0.007 260.03)", + "--color-info": "oklch(68.455% 0.148 237.251)", + "--color-info-content": "oklch(29% 0.066 243.157)", + "--color-success": "oklch(78.452% 0.132 181.911)", + "--color-success-content": "oklch(15.69% 0.026 181.911)", + "--color-warning": "oklch(83.242% 0.139 82.95)", + "--color-warning-content": "oklch(16.648% 0.027 82.95)", + "--color-error": "oklch(71.785% 0.17 13.118)", + "--color-error-content": "oklch(14.357% 0.034 13.118)", + "--radius-selector": "1rem", + "--radius-field": "0.5rem", + "--radius-box": "1rem", + "--size-selector": "0.25rem", + "--size-field": "0.25rem", + "--border": "1px", + "--depth": "0", + "--noise": "0", + }, +} diff --git a/packages/theme-plugin/src/types.ts b/packages/theme-plugin/src/types.ts new file mode 100644 index 00000000..d2d8e01b --- /dev/null +++ b/packages/theme-plugin/src/types.ts @@ -0,0 +1,42 @@ +export type ThemeTokens = { + "color-scheme": "light" | "dark" + "--color-base-100": string + "--color-base-200": string + "--color-base-300": string + "--color-base-content": string + "--color-primary": string + "--color-primary-content": string + "--color-secondary": string + "--color-secondary-content": string + "--color-accent": string + "--color-accent-content": string + "--color-neutral": string + "--color-neutral-content": string + "--color-info": string + "--color-info-content": string + "--color-success": string + "--color-success-content": string + "--color-warning": string + "--color-warning-content": string + "--color-error": string + "--color-error-content": string + "--radius-selector": string + "--radius-field": string + "--radius-box": string + "--size-selector": string + "--size-field": string + "--border": string + "--depth": string + "--noise": string +} + +export type Theme = { + name: string + light: ThemeTokens + dark: ThemeTokens +} + +export type ThemeSelection = { + name: string + isDefault: boolean +} diff --git a/packages/theme-plugin/tsconfig.json b/packages/theme-plugin/tsconfig.json new file mode 100644 index 00000000..14a04b33 --- /dev/null +++ b/packages/theme-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jongh/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} From 627dca406c58f84d79c1abb51c1202e596340169 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Mon, 9 Feb 2026 14:32:34 +0900 Subject: [PATCH 07/22] refactor(ui): replace PandaCSS with Tailwind v4 setup --- packages/ui/.storybook/index.css | 6 +- packages/ui/.storybook/main.ts | 3 +- packages/ui/eslint.config.mjs | 7 +- packages/ui/package.json | 16 +- packages/ui/panda.config.ts | 18 -- packages/ui/postcss.config.cjs | 3 - packages/ui/preset.ts | 386 ------------------------------- packages/ui/tsconfig.json | 1 - 8 files changed, 18 insertions(+), 422 deletions(-) delete mode 100644 packages/ui/panda.config.ts delete mode 100644 packages/ui/postcss.config.cjs delete mode 100644 packages/ui/preset.ts diff --git a/packages/ui/.storybook/index.css b/packages/ui/.storybook/index.css index d0d5ccd0..ce41c8a1 100644 --- a/packages/ui/.storybook/index.css +++ b/packages/ui/.storybook/index.css @@ -1,2 +1,6 @@ @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"); -@layer reset, base, tokens, recipes, utilities; +@import "tailwindcss"; +@plugin "@jongh/theme-plugin" { + themes: default --default; +} +@plugin "@jongh/animation-plugin"; diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index 7b87f2f6..739ba7ca 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -2,6 +2,7 @@ import { dirname, join } from "path" import type { StorybookConfig } from "@storybook/react-vite" import { mergeConfig } from "vite" import tsconfigPaths from "vite-tsconfig-paths" +import tailwindcss from "@tailwindcss/vite" const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], @@ -29,7 +30,7 @@ const config: StorybookConfig = { }, async viteFinal(config) { return mergeConfig(config, { - plugins: [tsconfigPaths({ root: "./" })], + plugins: [tsconfigPaths({ root: "./" }), tailwindcss()], }) }, } diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs index 3be369d8..af400428 100644 --- a/packages/ui/eslint.config.mjs +++ b/packages/ui/eslint.config.mjs @@ -9,11 +9,6 @@ export default [ }, }, { - ignores: [ - "styled-system/*", - ".storybook/*", - "postcss.config.*", - "storybook-static/*", - ], + ignores: [".storybook/*", "storybook-static/*"], }, ] diff --git a/packages/ui/package.json b/packages/ui/package.json index 43205126..edc324c0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,7 +5,6 @@ "type": "module", "scripts": { "lint": "eslint --max-warnings 0 --no-warn-ignored --fix", - "prepare": "panda codegen", "test": "vitest run", "test-storybook": "vitest", "storybook": "storybook dev -p 6006", @@ -14,20 +13,23 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", + "@jongh/animation-plugin": "workspace:*", + "@jongh/theme-plugin": "workspace:*", "@vitejs/plugin-react": "^4.3.1", + "clsx": "^2.1.1", "framer-motion": "^11.3.19", "lucide-react": "^0.475.0", - "panda-animation": "workspace:*", "radix-ui": "^1.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.56.3" + "react-hook-form": "^7.56.3", + "tailwind-merge": "^3.0.2", + "tailwind-variants": "^0.3.1" }, "devDependencies": { "@chromatic-com/storybook": "^4.0.0", "@jongh/eslint": "workspace:*", "@jongh/tsconfig": "workspace:*", - "@pandacss/studio": "^0.46.1", "@storybook/addon-a11y": "^9.0.5", "@storybook/addon-docs": "^9.0.5", "@storybook/addon-links": "^9.0.5", @@ -35,19 +37,21 @@ "@storybook/addon-themes": "^9.0.5", "@storybook/addon-vitest": "9.0.5", "@storybook/react-vite": "^9.0.5", + "@tailwindcss/vite": "^4.1.12", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "axe-playwright": "^2.0.1", "chromatic": "^11.5.4", + "eslint-plugin-better-tailwindcss": "^4.1.1", "playwright": "^1.48.2", "storybook": "^9.0.5", + "tailwindcss": "^4.1.12", "vite": "^5.4.10", "vite-bundle-visualizer": "^1.2.1", "vite-plugin-dts": "^3.9.1", - "vite-tsconfig-paths": "^5.1.4", - "@pandacss/dev": "catalog:" + "vite-tsconfig-paths": "^5.1.4" }, "keywords": [], "author": "", diff --git a/packages/ui/panda.config.ts b/packages/ui/panda.config.ts deleted file mode 100644 index 266a7794..00000000 --- a/packages/ui/panda.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "@pandacss/dev" -import { preset } from "panda-animation" - -import { defaultPreset } from "./preset" -export default defineConfig({ - preflight: true, - presets: [preset(), "@pandacss/preset-panda", defaultPreset], - jsxFramework: "react", - include: ["./src/**/*.{js,jsx,ts,tsx}", "./src/stories/*.{js,jsx,ts,tsx}"], - outdir: "styled-system", - strictPropertyValues: true, - strictTokens: true, - globalCss: { - body: { - fontFamily: "Pretendard Variable", - }, - }, -}) diff --git a/packages/ui/postcss.config.cjs b/packages/ui/postcss.config.cjs deleted file mode 100644 index 3fd0bc97..00000000 --- a/packages/ui/postcss.config.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - plugins: [require("@pandacss/dev/postcss")()], -} diff --git a/packages/ui/preset.ts b/packages/ui/preset.ts deleted file mode 100644 index 68b7933f..00000000 --- a/packages/ui/preset.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { - definePreset, - defineSemanticTokens, - defineTextStyles, - defineTokens, -} from "@pandacss/dev" - -const radii = defineTokens.radii({ - radius: { value: "0.5rem" }, -}) -//main - blue -//secondary - slate -// export const semanticColors = defineSemanticTokens.colors({ -// background: { -// value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, -// }, -// foreground: { -// value: { base: "{colors.slate.950}", _dark: "{colors.slate.50}" }, -// }, -// primary: { -// DEFAULT: { -// value: { base: "{colors.blue.600}", _dark: "{colors.blue.700}" }, -// }, -// foreground: { -// value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, -// }, -// active: { -// value: { base: "{colors.blue.700}", _dark: "{colors.blue.300}" }, -// }, -// }, -// secondary: { -// DEFAULT: { -// value: { base: "{colors.gray.600}", _dark: "{colors.gray.400}" }, -// }, -// foreground: { -// value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, -// }, -// active: { -// value: { base: "{colors.gray.700}", _dark: "{colors.gray.300}" }, -// }, -// }, -// neutral: { -// DEFAULT: { -// value: { base: "{colors.slate.100}", _dark: "{colors.slate.800}" }, -// }, -// foreground: { -// value: { base: "{colors.slate.500}", _dark: "{colors.slate.400}" }, -// }, -// active: { -// value: { base: "{colors.slate.200}", _dark: "{colors.slate.700}" }, -// }, -// }, -// accent: { -// DEFAULT: { -// value: { base: "{colors.gray.100}", _dark: "{colors.gray.800}" }, -// }, -// foreground: { -// value: { base: "{colors.gray.900}", _dark: "{colors.gray.50}" }, -// }, -// active: { -// value: { base: "{colors.gray.200}", _dark: "{colors.gray.700}" }, -// }, -// }, -// destructive: { -// DEFAULT: { -// value: { base: "{colors.red.600}", _dark: "{colors.red.400}" }, -// }, -// foreground: { -// value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, -// }, -// active: { -// value: { base: "{colors.red.700}", _dark: "{colors.red.300}" }, -// }, -// }, -// layer: { -// DEFAULT: { -// value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, -// }, -// overlay: { -// value: { base: "{colors.black/50}", _dark: "{colors.black/50}" }, -// }, -// floating: { -// value: { base: "{colors.slate.50}", _dark: "{colors.slate.900}" }, -// }, -// active: { -// value: { base: "{colors.slate.50}", _dark: "{colors.slate.800}" }, -// }, -// foreground: { -// DEFAULT: { -// value: { base: "{colors.slate.500}", _dark: "{colors.slate.400}" }, -// }, -// muted: { -// value: { base: "{colors.slate.400}", _dark: "{colors.slate.500}" }, -// }, -// emphasized: { -// value: { base: "{colors.slate.700}", _dark: "{colors.slate.200}" }, -// }, -// primary: { -// value: { base: "{colors.blue.500}", _dark: "{colors.blue.400}" }, -// }, -// }, -// }, -// stroke: { -// DEFAULT: { -// value: { base: "{colors.blue.200}", _dark: "{colors.blue.600}" }, -// }, -// border: { -// value: { base: "{colors.slate.200}", _dark: "{colors.slate.800}" }, -// }, -// input: { -// value: { base: "{colors.slate.200}", _dark: "{colors.slate.800}" }, -// }, -// ring: { -// value: { base: "{colors.blue.500}", _dark: "{colors.blue.400}" }, -// }, -// destructive: { -// value: { base: "{colors.red.500}", _dark: "{colors.red.400}" }, -// }, -// }, -// }) - -export const semanticColors = defineSemanticTokens.colors({ - background: { - value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, - }, - foreground: { - DEFAULT: { - value: { base: "{colors.slate.700}", _dark: "{colors.slate.300}" }, - }, - muted: { - value: { base: "{colors.slate.500}", _dark: "{colors.slate.400}" }, - }, - emphasized: { - value: { base: "{colors.slate.900}", _dark: "{colors.slate.100}" }, - }, - primary: { - value: { base: "{colors.blue.600}", _dark: "{colors.blue.400}" }, - }, - destructive: { - value: { base: "{colors.red.600}", _dark: "{colors.red.400}" }, - }, - inverted: { - value: { base: "{colors.white}", _dark: "{colors.slate.950}" }, - }, - }, - layer: { - DEFAULT: { value: { base: "{colors.white}", _dark: "{colors.slate.950}" } }, - floating: { - value: { base: "{colors.slate.50}", _dark: "{colors.slate.900}" }, - }, - active: { - value: { base: "{colors.slate.100}", _dark: "{colors.slate.800}" }, - }, - overlay: { - value: { base: "{colors.black/50}", _dark: "{colors.black/50}" }, - }, - }, - primary: { - DEFAULT: { - value: { base: "{colors.blue.600}", _dark: "{colors.blue.700}" }, - }, - active: { - value: { base: "{colors.blue.700}", _dark: "{colors.blue.300}" }, - }, - foreground: { - value: { base: "{colors.white}", _dark: "{colors.slate.50}" }, - }, - }, - secondary: { - DEFAULT: { - value: { base: "{colors.gray.700}", _dark: "{colors.gray.500}" }, - }, - active: { - value: { base: "{colors.gray.800}", _dark: "{colors.gray.400}" }, - }, - foreground: { - value: { base: "{colors.white}", _dark: "{colors.slate.50}" }, - }, - }, - neutral: { - DEFAULT: { - value: { base: "{colors.slate.100}", _dark: "{colors.slate.800}" }, - }, - active: { - value: { base: "{colors.slate.200}", _dark: "{colors.slate.700}" }, - }, - }, - destructive: { - DEFAULT: { value: { base: "{colors.red.600}", _dark: "{colors.red.400}" } }, - active: { value: { base: "{colors.red.700}", _dark: "{colors.red.300}" } }, - foreground: { - value: { base: "{colors.white}", _dark: "{colors.slate.50}" }, - }, - }, - stroke: { - DEFAULT: { - value: { base: "{colors.slate.300}", _dark: "{colors.slate.50}" }, - }, - subtle: { - value: { base: "{colors.slate.200}", _dark: "{colors.slate.800}" }, - }, - interactive: { - value: { base: "{colors.blue.400}", _dark: "{colors.blue.500}" }, - }, - ring: { value: { base: "{colors.blue.500}", _dark: "{colors.blue.400}" } }, - destructive: { - value: { base: "{colors.red.500}", _dark: "{colors.red.400}" }, - }, - }, -}) - -const borders = defineSemanticTokens.borders({ - base: { value: "1px solid {colors.stroke}" }, - input: { value: "1px solid {colors.stroke.subtle}" }, - primary: { value: "1px solid {colors.stroke.interactive}" }, - destructive: { value: "1px solid {colors.destructive}" }, -}) - -export const textStyles = defineTextStyles({ - // Display 계열 - display1: { - value: { - fontWeight: "{fontWeights.bold}", - fontSize: "4.5rem", // 6xl - lineHeight: "{lineHeights.loose}", - letterSpacing: "{letterSpacings.tight}", - }, - }, - display2: { - value: { - fontWeight: "{fontWeights.bold}", - fontSize: "3.75rem", // 5xl - lineHeight: "{lineHeights.relaxed}", - letterSpacing: "{letterSpacings.tight}", - }, - }, - // Title 계열 - title1: { - value: { - fontWeight: "semibold", - fontSize: "2.25rem", // 3xl - lineHeight: "{lineHeights.normal}", - letterSpacing: "{letterSpacings.tight}", - }, - }, - title2: { - value: { - fontWeight: "semibold", - fontSize: "1.5rem", // 2xl - lineHeight: "{lineHeights.snug}", - letterSpacing: "{letterSpacings.tight}", - }, - }, - title3: { - value: { - fontWeight: "{fontWeights.bold}", - fontSize: "1.25rem", // xl - lineHeight: "{lineHeights.snug}", - letterSpacing: "{letterSpacings.tight}", - }, - }, - // Heading 계열 - heading1: { - value: { - fontWeight: "{fontWeights.semibold}", - fontSize: "1.125rem", // lg - lineHeight: "{lineHeights.normal}", - letterSpacing: "{letterSpacings.normal}", - }, - }, - heading2: { - value: { - fontWeight: "{fontWeights.semibold}", - fontSize: "1rem", // md - lineHeight: "{lineHeights.normal}", - letterSpacing: "{letterSpacings.normal}", - }, - }, - // Body 계열 - body1: { - value: { - fontWeight: "{fontWeights.normal}", - fontSize: "1rem", // md - lineHeight: "{lineHeights.relaxed}", - letterSpacing: "{letterSpacings.normal}", - }, - }, - body2: { - value: { - fontWeight: "{fontWeights.normal}", - fontSize: "0.875rem", // sm - lineHeight: "{lineHeights.normal}", - letterSpacing: "{letterSpacings.normal}", - }, - }, - // Label/Caption 계열 - label1: { - value: { - fontWeight: "{fontWeights.semibold}", - fontSize: "0.875rem", // sm - lineHeight: "{lineHeights.snug}", - letterSpacing: "{letterSpacings.wide}", - }, - }, - label2: { - value: { - fontWeight: "{fontWeights.semibold}", - fontSize: "0.75rem", // xs - lineHeight: "{lineHeights.snug}", - letterSpacing: "{letterSpacings.wide}", - }, - }, - caption1: { - value: { - fontWeight: "{fontWeights.semibold}", - fontSize: "0.75rem", // xs - lineHeight: "{lineHeights.tight}", - letterSpacing: "{letterSpacings.wide}", - }, - }, - caption2: { - value: { - fontWeight: "{fontWeights.semibold}", - fontSize: "0.625rem", // 2xs - lineHeight: "{lineHeights.tight}", - letterSpacing: "{letterSpacings.wide}", - }, - }, -}) - -export const defaultPreset = definePreset({ - name: "default", - globalCss: { - body: { - background: "background", - color: "foreground", - }, - }, - theme: { - extend: { - textStyles, - tokens: { - radii, - }, - semanticTokens: { - colors: semanticColors, - borders, - }, - }, - }, - utilities: { - extend: { - fluidFontSize: { - shorthand: "fluidFontSize", - values: "fontSizes", - transform(value) { - if (!value.endsWith("rem")) { - return value - } - try { - const min = parseFloat(value) * 16 //html 1rem - const max = min + 0.5 * 16 //min +0.5rem - - const [minRange, maxRange] = [360, 640] - const slopePxPerVw = (100 * (max - min)) / (maxRange - minRange) - const intercept = (min - slopePxPerVw * (minRange / 100)) / 16 - const calcPart = `calc(${intercept.toFixed(4)}rem + ${slopePxPerVw.toFixed(4)}vw)` - - return { - fontSize: `clamp(${value}, ${calcPart}, ${max / 16}rem)`, - } - } catch (error) { - console.error( - "[fluidFontSize] Error transforming value:", - error, - value, - ) - return { - fontSize: value, - } - } - }, - }, - }, - }, -}) diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 2274baa3..cc9497ce 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "dist", "paths": { - "@styled-system/*": ["./styled-system/*"], "@utils/*": ["./src/utils/*"], "@/*": ["./src/*"], "react": ["./node_modules/@types/react"] From 6bdc6b8775ccdb0fe22f54c6260b9317f99bd012 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Mon, 9 Feb 2026 14:33:53 +0900 Subject: [PATCH 08/22] feat(ui): add Tailwind utility helpers (cn, createStyleContext, textStyle) --- packages/ui/src/utils/cn.ts | 6 ++ packages/ui/src/utils/createStyleContext.tsx | 88 ++++++++++++++++++++ packages/ui/src/utils/textStyle.ts | 15 ++++ 3 files changed, 109 insertions(+) create mode 100644 packages/ui/src/utils/cn.ts create mode 100644 packages/ui/src/utils/createStyleContext.tsx create mode 100644 packages/ui/src/utils/textStyle.ts diff --git a/packages/ui/src/utils/cn.ts b/packages/ui/src/utils/cn.ts new file mode 100644 index 00000000..d084ccad --- /dev/null +++ b/packages/ui/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/ui/src/utils/createStyleContext.tsx b/packages/ui/src/utils/createStyleContext.tsx new file mode 100644 index 00000000..7b3bc863 --- /dev/null +++ b/packages/ui/src/utils/createStyleContext.tsx @@ -0,0 +1,88 @@ +import { createContext, type ElementType, forwardRef, useContext } from "react" + +import { cn } from "./cn" + +type SlotFn = (props?: { className?: string }) => string +type SlotRecord = Record + +const StyleContext = createContext({}) + +type RecipeWithSlots = { + (...args: any[]): SlotRecord + variantKeys: Array +} + +function splitVariantProps( + props: Record, + variantKeys: Array, +): [Record, Record] { + const variants: Record = {} + const rest: Record = {} + + for (const [key, value] of Object.entries(props)) { + if (variantKeys.includes(key)) { + variants[key] = value + } else { + rest[key] = value + } + } + + return [variants, rest] +} + +export function createStyleContext(recipe: RecipeWithSlots) { + function withProvider(Component: ElementType, slot: string) { + const Comp = forwardRef((props, ref) => { + const [variantProps, rest] = splitVariantProps(props, recipe.variantKeys) + const slots = recipe(variantProps) + + return ( + + + + ) + }) + Comp.displayName = + (Component as any).displayName || (Component as any).name || "Component" + return Comp + } + + function withRootProvider(Component: ElementType) { + const Comp = forwardRef((props, ref) => { + const [variantProps, rest] = splitVariantProps(props, recipe.variantKeys) + const slots = recipe(variantProps) + + return ( + + + + ) + }) + Comp.displayName = + (Component as any).displayName || (Component as any).name || "Component" + return Comp + } + + function withContext(Component: ElementType, slot: string) { + const Comp = forwardRef((props, ref) => { + const slots = useContext(StyleContext) + + return ( + + ) + }) + Comp.displayName = + (Component as any).displayName || (Component as any).name || "Component" + return Comp + } + + return { withProvider, withRootProvider, withContext } +} diff --git a/packages/ui/src/utils/textStyle.ts b/packages/ui/src/utils/textStyle.ts new file mode 100644 index 00000000..957e27cf --- /dev/null +++ b/packages/ui/src/utils/textStyle.ts @@ -0,0 +1,15 @@ +export const textStyle = { + display1: "text-7xl font-bold leading-loose tracking-tight", + display2: "text-6xl font-bold leading-relaxed tracking-tight", + title1: "text-4xl font-semibold leading-normal tracking-tight", + title2: "text-2xl font-semibold leading-snug tracking-tight", + title3: "text-xl font-bold leading-snug tracking-tight", + heading1: "text-lg font-semibold leading-normal", + heading2: "text-base font-semibold leading-normal", + body1: "text-base leading-relaxed", + body2: "text-sm leading-normal", + label1: "text-sm font-semibold leading-snug tracking-wide", + label2: "text-xs font-semibold leading-snug tracking-wide", + caption1: "text-xs font-semibold leading-tight tracking-wide", + caption2: "text-[0.625rem] font-semibold leading-tight tracking-wide", +} as const From 22fa566e2f6bec6235eeb94aa43d9df998939b13 Mon Sep 17 00:00:00 2001 From: whdgur5717 Date: Mon, 9 Feb 2026 14:34:19 +0900 Subject: [PATCH 09/22] refactor(ui): migrate all component recipes to tailwind-variants --- .../ui/src/component/accordion/ui/index.tsx | 2 +- .../ui/src/component/accordion/ui/recipe.ts | 104 +++-------- .../src/component/animateButton/ui/index.tsx | 9 +- packages/ui/src/component/avatar/ui/index.tsx | 2 +- packages/ui/src/component/avatar/ui/recipe.ts | 34 +--- packages/ui/src/component/button/ui/index.tsx | 12 +- packages/ui/src/component/button/ui/recipe.ts | 125 +++---------- .../ui/src/component/calendar/ui/index.tsx | 67 +++---- .../ui/src/component/calendar/ui/recipe.ts | 116 +++--------- .../ui/src/component/checkbox/ui/index.tsx | 31 ++-- .../ui/src/component/checkbox/ui/recipe.ts | 171 ++++-------------- packages/ui/src/component/chip/ui/index.tsx | 11 +- packages/ui/src/component/chip/ui/recipe.ts | 116 +++--------- packages/ui/src/component/dialog/ui/index.tsx | 5 +- packages/ui/src/component/dialog/ui/recipe.ts | 103 +++-------- packages/ui/src/component/select/ui/index.tsx | 2 +- packages/ui/src/component/select/ui/recipe.ts | 162 ++++------------- packages/ui/src/component/slider/ui/index.tsx | 2 +- packages/ui/src/component/slider/ui/recipe.ts | 50 +---- packages/ui/src/component/tabs/ui/index.tsx | 2 +- packages/ui/src/component/tabs/ui/recipe.ts | 53 ++---- .../ui/src/component/textfield/ui/index.tsx | 25 ++- .../ui/src/component/textfield/ui/recipe.ts | 90 +++------ 23 files changed, 304 insertions(+), 990 deletions(-) diff --git a/packages/ui/src/component/accordion/ui/index.tsx b/packages/ui/src/component/accordion/ui/index.tsx index 00721218..2da7d636 100644 --- a/packages/ui/src/component/accordion/ui/index.tsx +++ b/packages/ui/src/component/accordion/ui/index.tsx @@ -1,4 +1,4 @@ -import { createStyleContext } from "@styled-system/jsx" +import { createStyleContext } from "@utils/createStyleContext" import { ChevronDown } from "lucide-react" import { Accordion as AccordionPrimitive } from "radix-ui" import { type ComponentPropsWithoutRef, ElementRef, forwardRef } from "react" diff --git a/packages/ui/src/component/accordion/ui/recipe.ts b/packages/ui/src/component/accordion/ui/recipe.ts index f03a2587..895d41e9 100644 --- a/packages/ui/src/component/accordion/ui/recipe.ts +++ b/packages/ui/src/component/accordion/ui/recipe.ts @@ -1,87 +1,35 @@ -import { type RecipeVariantProps, sva } from "@styled-system/css" +import { tv, type VariantProps } from "tailwind-variants" -export const recipe = sva({ - slots: ["root", "item", "header", "trigger", "content", "contentWrapper"], - base: { - root: {}, - item: {}, - header: { - display: "flex", - }, - trigger: { - alignItems: "center", - color: "foreground.emphasized", - cursor: "pointer", - display: "flex", - flex: "1", - justifyContent: "space-between", - px: "1", - py: "2", - textStyle: "heading2", - transition: "colors", - - "& > svg": { - flexShrink: "0", - h: "4", - transition: "transform", - transitionDuration: "normal", - w: "4", - }, - - "&[data-state=open] > svg": { - transform: "rotate(180deg)", - }, - }, - content: { - color: "foreground.muted", - overflow: "hidden", - textStyle: "body2", - transition: "all", - - "&[data-state=closed]": { - animationDuration: "normal", - animationName: "accordion-up_radix", - }, - - "&[data-state=open]": { - animationDuration: "normal", - animationName: "accordion-down_radix", - }, - }, - contentWrapper: { - px: "1", - py: "1", - }, +export const recipe = tv({ + slots: { + root: "", + item: "", + header: "flex", + trigger: [ + "flex flex-1 items-center justify-between cursor-pointer", + "px-1 py-2 text-base font-semibold leading-normal text-base-content", + "transition-colors", + "[&>svg]:shrink-0 [&>svg]:size-4 [&>svg]:transition-transform [&>svg]:duration-200", + "data-[state=open]:[&>svg]:rotate-180", + ], + content: [ + "overflow-hidden text-sm leading-normal text-base-content/60", + "transition-all", + "data-[state=closed]:animate-accordion-up", + "data-[state=open]:animate-accordion-down", + ], + contentWrapper: "px-1 py-1", }, variants: { variant: { outline: { - trigger: { - _hover: { - color: "primary", - }, - }, - item: { - borderBottom: "base", - }, + trigger: "hover:text-primary", + item: "border-b border-base-300", }, subtle: { - item: { - borderRadius: "md", - "&[data-state=open]": { - bg: "neutral", - }, - }, - header: { - borderRadius: "md", - }, - trigger: { - borderRadius: "md", - _hover: { - bg: "neutral", - borderRadius: "md", - }, - }, + item: "rounded-md data-[state=open]:bg-neutral", + header: "rounded-md", + trigger: "rounded-md hover:bg-neutral hover:rounded-md", }, }, }, @@ -90,4 +38,4 @@ export const recipe = sva({ }, }) -export type AccordionVariants = RecipeVariantProps +export type AccordionVariants = VariantProps diff --git a/packages/ui/src/component/animateButton/ui/index.tsx b/packages/ui/src/component/animateButton/ui/index.tsx index a7bcef7a..c60455a0 100644 --- a/packages/ui/src/component/animateButton/ui/index.tsx +++ b/packages/ui/src/component/animateButton/ui/index.tsx @@ -1,4 +1,3 @@ -import { token } from "@styled-system/tokens" import { motion, type Variants } from "framer-motion" import type { ComponentProps, ComponentRef } from "react" import { forwardRef } from "react" @@ -11,6 +10,8 @@ export type AnimateButtonProps = { initialAnimation?: keyof typeof variants } & ComponentProps +const DURATION = 0.5 + const variants = { initial: { scale: 1, @@ -23,7 +24,7 @@ const variants = { pulse: { scale: [1, 1.05, 1, 1.05, 1], transition: { - duration: parseFloat(token("durations.slower")) / 1000 || 0.5, + duration: DURATION, ease: "easeInOut", times: [0, 0.25, 0.5, 0.75, 1], }, @@ -31,7 +32,7 @@ const variants = { bounce: { y: [0, -6, 0, -3, 0], transition: { - duration: parseFloat(token("durations.slower")) / 1000 || 0.5, + duration: DURATION, ease: "easeOut", times: [0, 0.25, 0.5, 0.75, 1], }, @@ -39,7 +40,7 @@ const variants = { shake: { x: [0, -6, 6, -3, 3, 0], transition: { - duration: parseFloat(token("durations.slower")) / 1000 || 0.5, + duration: DURATION, ease: "easeInOut", }, }, diff --git a/packages/ui/src/component/avatar/ui/index.tsx b/packages/ui/src/component/avatar/ui/index.tsx index 73343ae9..140ee810 100644 --- a/packages/ui/src/component/avatar/ui/index.tsx +++ b/packages/ui/src/component/avatar/ui/index.tsx @@ -1,4 +1,4 @@ -import { createStyleContext } from "@styled-system/jsx" +import { createStyleContext } from "@utils/createStyleContext" import { Avatar as AvatarPrimitive } from "radix-ui" import { recipe } from "./recipe" diff --git a/packages/ui/src/component/avatar/ui/recipe.ts b/packages/ui/src/component/avatar/ui/recipe.ts index cdfb9470..8c617754 100644 --- a/packages/ui/src/component/avatar/ui/recipe.ts +++ b/packages/ui/src/component/avatar/ui/recipe.ts @@ -1,30 +1,10 @@ -import { sva } from "@styled-system/css" +import { tv } from "tailwind-variants" -export const recipe = sva({ - slots: ["root", "image", "fallback"], - base: { - root: { - position: "relative", - display: "flex", - h: "12", - w: "12", - flexShrink: "0", - overflow: "hidden", - rounded: "full", - }, - image: { - aspectRatio: "square", - h: "full", - w: "full", - }, - fallback: { - display: "flex", - h: "full", - w: "full", - alignItems: "center", - justifyContent: "center", - rounded: "full", - bg: "neutral", - }, +export const recipe = tv({ + slots: { + root: "relative flex size-12 shrink-0 overflow-hidden rounded-full", + image: "aspect-square h-full w-full", + fallback: + "flex h-full w-full items-center justify-center rounded-full bg-neutral", }, }) diff --git a/packages/ui/src/component/button/ui/index.tsx b/packages/ui/src/component/button/ui/index.tsx index d8ebbc80..d79bd658 100644 --- a/packages/ui/src/component/button/ui/index.tsx +++ b/packages/ui/src/component/button/ui/index.tsx @@ -1,24 +1,22 @@ -import { css, cx } from "@styled-system/css" +import { cn } from "@utils/cn" import { Slot } from "radix-ui" import type { ComponentPropsWithoutRef } from "react" import { forwardRef } from "react" -import { ButtonVariantProps, recipe } from "./recipe" +import { type ButtonVariantProps, recipe } from "./recipe" export type ButtonProps = ComponentPropsWithoutRef<"button"> & { asChild?: boolean } & ButtonVariantProps export const Button = forwardRef( - ({ asChild, className, ...props }, ref) => { + ({ asChild, className, size, variant, ...props }, ref) => { const Comp = asChild ? Slot.Root : "button" - const [variantProps, componentProps] = recipe.splitVariantProps(props) - const styles = recipe.raw(variantProps) return ( ) }, diff --git a/packages/ui/src/component/button/ui/recipe.ts b/packages/ui/src/component/button/ui/recipe.ts index 791c3450..7898d580 100644 --- a/packages/ui/src/component/button/ui/recipe.ts +++ b/packages/ui/src/component/button/ui/recipe.ts @@ -1,114 +1,33 @@ -import { cva, type RecipeVariantProps } from "@styled-system/css" +import { tv, type VariantProps } from "tailwind-variants" -export type ButtonVariantProps = RecipeVariantProps +export type ButtonVariantProps = VariantProps -export const recipe = cva({ - base: { - alignItems: "center", - cursor: "pointer", - display: "inline-flex", - flexShrink: 0, - gap: "1", - justifyContent: "center", - minH: "9", - rounded: "md", - textStyle: "label1", - whiteSpace: "nowrap", - "& svg": { - flexShrink: 0, - }, - _disabled: { - cursor: "not-allowed", - opacity: "0.5", - }, - }, +export const recipe = tv({ + base: [ + "inline-flex items-center justify-center gap-1 shrink-0", + "min-h-9 rounded-md whitespace-nowrap cursor-pointer", + "text-sm font-semibold leading-snug tracking-wide", + "[&_svg]:shrink-0", + "disabled:cursor-not-allowed disabled:opacity-50", + ], variants: { size: { - sm: { - h: "9", - px: "3", - py: "1.5", - textStyle: "label2", - }, - md: { - h: "10", - px: "4", - py: "2", - }, - lg: { - h: "11", - px: "5", - py: "2", - }, + sm: "h-9 px-3 py-1.5 text-xs font-semibold [&_svg]:size-3.5", + md: "h-10 px-4 py-2 [&_svg]:size-4", + lg: "h-11 px-5 py-2 [&_svg]:size-5", }, variant: { - destructive: { - bg: "destructive", - color: "destructive.foreground", - "&:not(:disabled):hover": { - bg: "destructive.active", - }, - }, - link: { - color: "foreground.primary", - textUnderlineOffset: "2", - "&:not(:disabled):hover": { - textDecoration: "underline", - }, - }, - outline: { - bg: "layer", - border: "base", - color: "foreground.emphasized", - "&:not(:disabled):hover": { - bg: "neutral.active", - }, - }, - primary: { - bg: "primary", - color: "primary.foreground", - "&:not(:disabled):hover": { - bg: "primary.active", - }, - }, - secondary: { - bg: "secondary", - color: "secondary.foreground", - "&:not(:disabled):hover": { - bg: "secondary.active", - }, - }, + primary: + "bg-primary text-primary-content hover:not-disabled:brightness-90", + secondary: + "bg-secondary text-secondary-content hover:not-disabled:brightness-90", + destructive: + "bg-error text-error-content hover:not-disabled:brightness-90", + outline: + "bg-base-100 border border-base-300 text-base-content hover:not-disabled:bg-base-200", + link: "text-primary underline-offset-2 hover:not-disabled:underline", }, }, - compoundVariants: [ - { - size: "sm", - css: { - "& svg": { - height: "3.5", - width: "3.5", - }, - }, - }, - { - size: "md", - css: { - "& svg": { - height: "4", - width: "4", - }, - }, - }, - { - size: "lg", - css: { - "& svg": { - height: "5", - width: "5", - }, - }, - }, - ], defaultVariants: { size: "md", variant: "primary", diff --git a/packages/ui/src/component/calendar/ui/index.tsx b/packages/ui/src/component/calendar/ui/index.tsx index 0b61dfa3..7fb2f2ee 100644 --- a/packages/ui/src/component/calendar/ui/index.tsx +++ b/packages/ui/src/component/calendar/ui/index.tsx @@ -1,6 +1,6 @@ "use client" -import { css, cx } from "@styled-system/css" +import { cn } from "@utils/cn" import { ChevronLeft, ChevronRight } from "lucide-react" import { Context, useControllableState } from "radix-ui/internal" import { @@ -48,9 +48,9 @@ interface CalendarBaseProps extends ComponentPropsWithoutRef<"div"> { } interface CalendarViewProps { - viewDate?: Date // 제어: 현재 보여지는 월/년 기준 날짜 - defaultViewDate?: Date // 비제어: 초기 보여지는 월/년 기준 날짜 - onViewDateChange?: (date: Date) => void // viewDate 변경 시 콜백 + viewDate?: Date + defaultViewDate?: Date + onViewDateChange?: (date: Date) => void } export interface CalendarRootProps @@ -66,10 +66,10 @@ export interface CalendarRootProps export interface CalendarRangeProps extends CalendarBaseProps, CalendarViewProps { - type: "range" // 구별자 - range?: RangeDate // 제어: 선택된 날짜 범위 - defaultRange?: RangeDate // 비제어: 초기 선택된 날짜 범위 - onRangeChange?: (range: RangeDate | undefined) => void // range 변경 시 콜백 + type: "range" + range?: RangeDate + defaultRange?: RangeDate + onRangeChange?: (range: RangeDate | undefined) => void } export const Root = (props: CalendarRootProps | CalendarRangeProps) => { @@ -168,37 +168,26 @@ export const RangeCalendar = forwardRef( const handleDayClick = useCallback( (clickedDate: Date) => { - // 시간을 0으로 설정하여 날짜만 비교 (정확한 비교 위함) const normalizedClickedDate = new Date(clickedDate) normalizedClickedDate.setHours(0, 0, 0, 0) - // 현재 상태(prevRange)를 받아 다음 상태를 반환하는 함수형 업데이트 사용 setRangeValue((prevRange) => { const [start, end] = prevRange || [null, null] - // 시작 날짜의 시간도 0으로 설정 (비교를 위해) const normalizedStart = start ? new Date(start) : null if (normalizedStart) normalizedStart.setHours(0, 0, 0, 0) - // 1. 시작 날짜가 없는 경우: 클릭한 날짜를 새 시작 날짜로 설정 if (!normalizedStart) { - return [clickedDate, null] // 원본 Date 객체 저장 + return [clickedDate, null] } - // 2. 시작 날짜만 있고 종료 날짜는 없는 경우: if (normalizedStart && !end) { - // 클릭한 날짜가 시작 날짜보다 이전이면 -> 클릭한 날짜를 새 시작 날짜로 설정 (범위 리셋) if (normalizedClickedDate < normalizedStart) { return [clickedDate, null] - } - // 클릭한 날짜가 시작 날짜와 같거나 이후면 -> 클릭한 날짜를 종료 날짜로 설정 - else { - // 시작 날짜와 동일한 날짜를 클릭하면 종료 날짜도 시작 날짜와 동일하게 설정할지, - // 아니면 아무것도 안할지 정책 결정 필요 (여기선 종료로 설정) - return [start, clickedDate] // 원본 Date 객체 저장 + } else { + return [start, clickedDate] } } - // 3. 시작 날짜와 종료 날짜가 모두 있는 경우: 클릭한 날짜를 새 시작 날짜로 설정 (범위 리셋) if (normalizedStart && end) { return [clickedDate, null] } @@ -247,7 +236,7 @@ export const SingleCalendar = forwardRef( prop: viewDate, defaultProp: defaultViewDate, onChange: onViewDateChange, - }) //화면에 보여지는 날짜 + }) const [selectedValue = null, setSelectedValue] = useControllableState({ prop: date, @@ -306,7 +295,7 @@ export const SingleCalendar = forwardRef( } }, [dateValue, weekStart]) - const styles = recipe.raw() + const styles = recipe() return ( ( selectedValue={selectedValue ? [selectedValue] : []} handleDayClick={(clickedDate: Date) => setSelectedValue(clickedDate)} > -
+
{children}
@@ -339,7 +328,7 @@ export const Header = ({ render, }: HeaderProps) => { const { value, onMonthChange, locale } = useCalendarContext(contextScopeName) - const styles = recipe.raw() + const styles = recipe() const monthAndYear = new Intl.DateTimeFormat(locale, { month: month, @@ -347,21 +336,21 @@ export const Header = ({ }).format(new Date(value.year, value.month - 1)) return ( -
+
-
+
{render ? render(new Date(value.year, value.month - 1), locale) : `${monthAndYear}`}