AgentSkillsCN

pen-to-react

将 .pen 设计文件转换为 Synnovator 前端的 React 组件。采用两阶段工作流:(1) 从 .pen 设计中提取可复用的基础 UI 组件,并将其存入 frontend/components/ui/ 目录;(2) 在 frontend/components/pages/ 目录中,利用这些基础组件组合成完整的页面组件。对于那些不值得单独提取的元素,则直接内联到页面组件中。适用场景如下:(1) 将 .pen 设计文件转换为 React 页面与 UI 组件;(2) 根据 .pen 设计新增一个页面组件;(3) 更新现有组件,使其与修订后的 .pen 设计保持一致;(4) 从 .pen 中提取可复用的 UI 模式,并将其封装为共享组件。

SKILL.md
--- frontmatter
name: pen-to-react
description: >
  Convert .pen design files into React components for the Synnovator frontend.
  Two-phase workflow: (1) Extract reusable base UI components from .pen designs
  into frontend/components/ui/, (2) Compose full page components using those
  base components in frontend/components/pages/. Elements not worth extracting
  are inlined directly into the page component.
  Use when:
  (1) Converting a .pen design file into React page and UI components
  (2) Adding a new page component based on a .pen design
  (3) Updating existing components to match revised .pen designs
  (4) Extracting reusable UI patterns from .pen into shared components

Pen to React

Convert Pencil (.pen) design files into production React components for the Synnovator platform.

Prerequisites

  • .pen design files in specs/ui/components/ (one per page)
  • Design system references in specs/ui/style.pen and specs/ui/basic.pen
  • Frontend project at frontend/ with Next.js 14, Tailwind v4, shadcn/ui

Tech Stack

LayerTechnology
FrameworkNext.js 14 (App Router)
LanguageTypeScript
StylingTailwind CSS v4 (@import "tailwindcss")
UI Libraryshadcn/ui (Radix UI primitives)
Iconslucide-react
FontsSpace Grotesk (heading), Inter (body), Poppins (mono), Noto Sans SC (Chinese)
ThemeNeon Forge dark theme

Theme Tokens (CSS Variables)

All colors MUST use CSS variables — never hardcode hex values in components.

code
--nf-lime: #BBFD3B        (primary accent)
--nf-surface: #00000E     (deepest background)
--nf-near-black: #181818  (page background)
--nf-card-bg: #222222     (card/panel background)
--nf-dark-bg: #333333     (borders, secondary background)
--nf-muted: #8E8E8E       (muted text, icons)
--nf-light-gray: #DCDCDC  (secondary text)
--nf-white: #FFFFFF        (primary text)
--nf-error: #FA541C       --nf-success: #74FFBB
--nf-warning: #FAAD14     --nf-cyan: #41FAF4
--nf-blue: #4C78FF        --nf-pink: #FF74A7
--nf-orange: #FB7A38

Font utility classes (defined in globals.css via @theme inline):

  • font-heading → Space Grotesk
  • font-body → Inter
  • font-mono → Poppins
  • font-chinese → Noto Sans SC

Output Structure

code
frontend/components/
├── ui/             # Reusable base components (shadcn + custom extracted)
│   ├── button.tsx        # (existing shadcn)
│   ├── card.tsx          # (existing shadcn)
│   ├── avatar.tsx        # (existing shadcn)
│   ├── badge.tsx         # (existing shadcn)
│   ├── tabs.tsx          # (existing shadcn)
│   ├── ...               # other existing shadcn components
│   └── stat-card.tsx     # (example: newly extracted from .pen)
├── pages/          # Full page components composed from ui/ components
│   ├── home.tsx
│   ├── post-list.tsx
│   ├── post-detail.tsx
│   ├── proposal-list.tsx
│   ├── proposal-detail.tsx
│   ├── category-detail.tsx
│   ├── user-profile.tsx
│   ├── team.tsx
│   ├── assets.tsx
│   └── following-list.tsx
└── interactive/    # Client components for user interactions (future)

Workflow

Phase 1: Analyze the .pen Design

1.1 Open and Read the Design

Use Pencil MCP tools to read the .pen file:

code
1. Open the document:
   open_document("specs/ui/components/{page-name}.pen")

2. Get editor state to understand structure:
   get_editor_state(include_schema=false)

3. Read the top-level frame and its full tree:
   batch_get(readDepth=10)

4. If the design has reusable components:
   batch_get(patterns=[{reusable: true}], readDepth=3, searchDepth=5)

5. Take a screenshot for visual reference:
   get_screenshot(nodeId="{top-frame-id}")

1.2 Identify Extractable UI Components

Analyze the design tree and identify elements that should be extracted into components/ui/:

Extract as reusable component IF:

  • The element appears 2+ times in this design OR across multiple pages
  • It is marked as reusable: true in the .pen file
  • It has a clear, generic purpose (card, stat display, avatar group, etc.)
  • It maps to a recognizable UI pattern (list item, grid card, header section)

Inline directly into page component IF:

  • The element appears only once and is page-specific
  • It is a simple layout wrapper (section container, spacer)
  • Extracting would create a component with too many props to be useful

1.3 Check Existing Components

Before creating new components, check what already exists:

code
frontend/components/ui/
├── avatar.tsx    → Avatar, AvatarFallback (Radix-based)
├── badge.tsx     → Badge with variant support
├── button.tsx    → Button with size/variant (CVA-based)
├── card.tsx      → Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription
├── dropdown-menu.tsx → DropdownMenu (Radix-based)
├── input.tsx     → Input
├── scroll-area.tsx → ScrollArea (Radix-based)
├── separator.tsx → Separator
├── tabs.tsx      → Tabs, TabsList, TabsTrigger, TabsContent (Radix-based)
├── tooltip.tsx   → Tooltip (Radix-based)

If a design element maps to an existing shadcn component, USE the existing component — do not recreate it.

Phase 2: Extract Base UI Components

For each element identified as extractable in Phase 1:

2.1 Extract Component Definition from .pen

code
1. Read the component node with full depth:
   batch_get(nodeIds=["{component-id}"], readDepth=5)

2. If it contains path geometry (SVGs, icons):
   batch_get(nodeIds=["{component-id}"], includePathGeometry=true)

3. Take a screenshot for visual reference:
   get_screenshot(nodeId="{component-id}")

2.2 Create the React Component

Follow these rules when translating .pen nodes to React:

Node Type Mapping:

.pen node typeReact element
frame (layout: horizontal)<div className="flex">
frame (layout: vertical)<div className="flex flex-col">
frame (layout: none)<div className="relative">
text<span> or <p> or <h1>-<h6>
rectangle<div> with sizing and fill
ellipse<div className="rounded-full">
path<svg><path d="..."/></svg>
icon_font<IconName> from lucide-react
ref (component instance)<ComponentName {...overrides}>

Sizing Translation:

.pen valueTailwind class
width: "fill_container"w-full or flex-1 (in flex context)
height: "fill_container"h-full or flex-1 (in flex context)
width: "fit_content"w-fit
height: "fit_content"h-fit
width: N (number)w-[Npx]
height: N (number)h-[Npx]

Color Translation: Always use CSS variables from the Neon Forge theme:

.pen fill/colorTailwind class
#BBFD3Bbg-[var(--nf-lime)] or text-[var(--nf-lime)]
#181818bg-[var(--nf-near-black)]
#222222bg-[var(--nf-card-bg)]
#333333bg-[var(--nf-dark-bg)] or border-[var(--nf-dark-bg)]
#8E8E8Etext-[var(--nf-muted)]
#DCDCDCtext-[var(--nf-light-gray)]
#FFFFFFtext-[var(--nf-white)]

Typography Translation:

.pen fontTailwind class
Space Groteskfont-heading
Interfont-body
Poppinsfont-mono
Noto Sans SCfont-chinese
fontSize: Ntext-[Npx]
fontWeight: "bold"font-bold
fontWeight: "semibold"font-semibold
fontWeight: "medium"font-medium

Spacing Translation:

.pen propertyTailwind class
gap: Ngap-[Npx] (or gap-N if matches scale)
padding: Np-[Npx]
padding: [top, right, bottom, left]pt-[] pr-[] pb-[] pl-[]
cornerRadius: [N,N,N,N]rounded-[Npx]

2.3 Component File Template

typescript
// frontend/components/ui/{component-name}.tsx
import { cn } from "@/lib/utils"

interface {ComponentName}Props {
  // Props derived from .pen instance overrides
  className?: string
}

export function {ComponentName}({ className, ...props }: {ComponentName}Props) {
  return (
    <div className={cn(
      // Base styles from .pen component definition
      "...",
      className
    )}>
      {/* Children from .pen component tree */}
    </div>
  )
}

2.4 Validate Component

After creating each component:

  1. Compare the component's visual output against the .pen screenshot
  2. Verify all CSS variables are correctly mapped
  3. Ensure the component accepts the right props for all .pen instance overrides
  4. Check that existing shadcn components are reused where applicable

Phase 3: Compose Page Component

3.1 Build the Full Page

Assemble the page component from:

  • Extracted base components from Phase 2
  • Existing shadcn/ui components
  • Inlined elements (not worth extracting)
  • lucide-react icons

Page Component Template:

typescript
// frontend/components/pages/{page-name}.tsx
"use client"

import { /* icons */ } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
// ... other imports

// Mock data (to be replaced by API calls via openapi-to-components skill)
const mockData = [...]

export function {PageName}() {
  return (
    <div className="flex flex-col h-screen bg-[var(--nf-near-black)]">
      {/* Header */}
      <header className="flex items-center justify-between h-14 px-6 border-b border-[var(--nf-dark-bg)] bg-[var(--nf-near-black)]">
        {/* ... */}
      </header>

      {/* Body */}
      <div className="flex flex-1 overflow-hidden">
        {/* Sidebar */}
        <aside className="w-[60px] bg-[var(--nf-near-black)] flex flex-col items-center pt-4 gap-4">
          {/* ... */}
        </aside>

        {/* Main Content */}
        <main className="flex-1 overflow-y-auto px-8 py-6 flex flex-col gap-6">
          {/* Page-specific content */}
        </main>
      </div>
    </div>
  )
}

3.2 Shared Layout Elements

Every page shares these layout elements (translate from .pen consistently):

Global Header:

  • Height: h-14 (56px)
  • Background: bg-[var(--nf-near-black)]
  • Border bottom: border-b border-[var(--nf-dark-bg)]
  • Contains: hamburger menu, logo text "协创者", search bar, action button, notifications, avatar

Left Sidebar (Icon-only):

  • Width: w-[60px]
  • Icons: Compass, Globe, Mountain (from lucide-react)
  • Icon color: text-[var(--nf-muted)]

Main Content Area:

  • flex-1 overflow-y-auto
  • Padding: px-8 py-6
  • Vertical layout: flex flex-col gap-6

3.3 Mock Data Pattern

Page components initially use hardcoded mock data. Use const declarations at the top of the file:

typescript
// Mock data — will be replaced by API integration
const cards = [
  { id: 1, title: "...", author: "..." },
  // ...
]

This mock data will later be replaced by real API calls when the openapi-to-components skill is applied.

Phase 4: Visual Validation

After composing the page component:

  1. Screenshot Comparison:

    code
    get_screenshot(nodeId="{page-frame-id}")
    

    Compare design screenshot against the rendered React component.

  2. Checklist:

    • All text content matches the .pen labels exactly (including Chinese text)
    • All icons match (lucide-react equivalents)
    • Colors use CSS variables, not hardcoded hex
    • Spacing (gap, padding) matches .pen values
    • Border radius matches .pen cornerRadius
    • Typography (font family, size, weight) matches
    • Layout structure (flex direction, alignment) matches
    • Responsive behavior: fill_containerflex-1/w-full/h-full
    • No inline styles — all styling via Tailwind classes
    • Existing shadcn components used where applicable
  3. TypeScript Verification:

    bash
    cd frontend && npx tsc --noEmit
    

Component-to-Page Mapping

.pen Design FilePage ComponentKey UI Components Used
home.penhome.tsxCard, Badge, Avatar, Tabs
post-list.penpost-list.tsxCard, Badge, Tabs
post-detail.penpost-detail.tsxCard, Badge, Avatar, ScrollArea
proposal-list.penproposal-list.tsxCard, Badge, Tabs
proposal-detail.penproposal-detail.tsxCard, Badge, Avatar, Tabs
category-detail.pencategory-detail.tsxCard, Badge, Tabs
user-profile.penuser-profile.tsxCard, Avatar, Badge, Tabs
team.penteam.tsxCard, Avatar, Badge, Tabs
assets.penassets.tsxCard, Badge, DropdownMenu
following-list.penfollowing-list.tsxCard, Avatar, Badge

Conventions

Naming

  • Component files: kebab-case.tsx (e.g., stat-card.tsx)
  • Component exports: PascalCase (e.g., StatCard)
  • Props interfaces: {ComponentName}Props
  • CSS variable references: var(--nf-{token-name})

Imports

typescript
// 1. React/Next.js imports
import { useState } from "react"

// 2. Icon imports
import { Menu, Search, Zap } from "lucide-react"

// 3. UI component imports (shadcn)
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"

// 4. Custom UI component imports
import { StatCard } from "@/components/ui/stat-card"

// 5. Type imports
import type { Post } from "@/lib/types"

Decision Tree: Extract vs Inline

code
Is the element used in 2+ pages?
├─ YES → Extract to components/ui/
└─ NO
   ├─ Is it a recognizable UI pattern (card variant, stat display, etc.)?
   │  ├─ YES → Extract to components/ui/ (likely reusable later)
   │  └─ NO
   │     ├─ Is it a complex sub-section with its own layout?
   │     │  ├─ YES → Consider extraction for readability
   │     │  └─ NO → Inline in page component
   │     └─ Is the element a simple wrapper or spacer?
   │        └─ YES → Inline in page component
   └─ Is the element already available as shadcn/ui component?
      └─ YES → Use existing shadcn component directly

SVG and Path Handling

When a .pen design contains path nodes (custom shapes, logos):

  1. Extract exact geometry using batch_get with includePathGeometry=true
  2. Use the geometry string directly as the SVG d attribute
  3. Set viewBox="0 0 {width} {height}" from the node dimensions
  4. Apply fill/stroke via Tailwind: fill-[var(--nf-lime)], stroke-[var(--nf-dark-bg)]
  5. Never approximate or simplify path data
tsx
<svg className="w-6 h-6" viewBox="0 0 24 24">
  <path
    d="{exact geometry from .pen}"
    className="fill-[var(--nf-lime)]"
  />
</svg>

Integration with Other Skills

This skill generates initial component code with mock data. After running this skill:

  1. openapi-to-components replaces mock data with real API calls:

    • Converts "use client" pages to async Server Components
    • Adds API fetch functions from lib/api/*.ts
    • Extracts interactive elements to components/interactive/
  2. synnovator manages the underlying data model that the API serves

The recommended order is:

code
pen-to-react → openapi-to-components → (deploy)