Bidirectional sync between Pencil.dev designs and frontend code, powered by Claude Code.
Edit a component in Pencil — code updates automatically. Change code — the design follows. pencil-sync watches .pen files and your source tree, detects conflicts, and uses the Claude CLI to propagate changes in either direction — with budget controls, conflict resolution, and sync loop prevention.
- Node.js >= 20
- Claude CLI installed and authenticated
npm install
npm run build
npm link # makes pencil-sync available globally on PATH# Generate a config file
pencil-sync init
# Edit pencil-sync.config.json to point to your .pen file and code directory
# Run a one-time sync
pencil-sync sync
# Start watching for changes
pencil-sync watchTo sync a project that lives in a separate repo, create a pencil-sync.config.json in that project and point --config at it:
# 1. Create a config in your project
cd /path/to/my-project
cat > pencil-sync.config.json << 'EOF'
{
"version": 1,
"mappings": [
{
"id": "my-app",
"penFile": "./design.pen",
"codeDir": "./src",
"codeGlobs": ["components/**/*.tsx", "app/**/*.tsx", "**/*.css"],
"direction": "both"
}
],
"settings": {
"model": "claude-sonnet-4-6",
"maxBudgetUsd": 0.5
}
}
EOF
# 2. One-time sync
pencil-sync sync --config /path/to/my-project/pencil-sync.config.json
# 3. Or watch for live changes
pencil-sync watch --config /path/to/my-project/pencil-sync.config.json
# 4. Check status
pencil-sync status --config /path/to/my-project/pencil-sync.config.jsonAll paths in the config (penFile, codeDir, stateFile) are resolved relative to the config file's directory, so you can run pencil-sync from anywhere.
# Sync only design-to-code
pencil-sync sync -c ./pencil-sync.config.json -d pen-to-code
# Watch a specific mapping (useful with multiple mappings)
pencil-sync watch -c ./pencil-sync.config.json -m my-app| Command | Description |
|---|---|
pencil-sync init |
Create a starter config file in the current directory |
pencil-sync sync |
Run a one-time sync for all (or a specific) mapping |
pencil-sync watch |
Start auto-sync file watcher |
pencil-sync status |
Show sync state for all mappings |
-c, --config <path> Path to config file
-v, --verbose Enable debug logging
pencil-sync sync -d pen-to-code # Force design-to-code direction
pencil-sync sync -d code-to-pen # Force code-to-design direction
pencil-sync sync -m my-app # Sync a specific mapping only
pencil-sync sync -n # Dry run: preview what would change without writing files
Config file: pencil-sync.config.json (also supports .pencil-sync.json and JSONC with comments)
{
"version": 1,
"mappings": [
{
"id": "my-app",
"penFile": "./design.pen",
"codeDir": "./src",
"codeGlobs": ["components/**/*.tsx", "app/**/*.tsx", "*.css"],
"direction": "both"
}
],
"settings": {
"debounceMs": 2000,
"model": "claude-sonnet-4-6",
"maxBudgetUsd": 0.5,
"conflictStrategy": "prompt",
"stateFile": ".pencil-sync-state.json",
"logLevel": "info"
}
}| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier for this mapping |
penFile |
Yes | Path to the .pen design file (relative to config) |
codeDir |
Yes | Path to the code directory (relative to config) |
codeGlobs |
Yes | Glob patterns for code files to track |
direction |
Yes | "both", "pen-to-code", or "code-to-pen" |
penScreens |
No | Specific screens to sync (defaults to all) |
framework |
No | Auto-detected: nextjs, react, vue, svelte, astro |
styling |
No | Auto-detected: tailwind, styled-components, css-modules, css |
styleFiles |
No | CSS/config files with design tokens (e.g., ["app/globals.css", "tailwind.config.js"]). Enables the color fast path and provides context to Claude for other changes. |
| Setting | Default | Description |
|---|---|---|
debounceMs |
2000 |
Debounce delay for file change events |
model |
claude-sonnet-4-6 |
Claude model to use |
maxBudgetUsd |
0.5 |
Maximum spend per session (enforced) |
conflictStrategy |
prompt |
How to handle conflicts: prompt, pen-wins, code-wins, auto-merge |
stateFile |
.pencil-sync-state.json |
Path to sync state file |
logLevel |
info |
Log level: debug, info, warn, error |
File Change (chokidar)
-> debounced trigger
-> SyncEngine.syncMapping()
-> LockManager.acquire()
-> ConflictDetector (hash comparison)
-> syncPenToCode() or syncCodeToPen()
-> Snapshot .pen nodes, diff against previous state
-> Fill changes: direct CSS variable replacement (fast path)
-> Other changes: build prompt → spawn Claude CLI → diff file hashes
-> StateStore.updateMappingState()
-> LockManager.release() (with grace period)
Fill/color changes are applied directly by pencil-sync as a find-and-replace in your CSS file. This is faster and more reliable than Claude CLI for colors because it's a mechanical hex→RGB conversion — no reasoning needed. Claude CLI is still used for text, typography, and layout changes that require understanding the component structure.
When a .pen node's fill property changes:
- The old and new hex values are converted to space-separated RGB channels (
#224846→34 72 70) - All CSS variable declarations matching the old RGB value are replaced with the new RGB in every theme block (
:root,[data-theme="monokai"],[data-theme="nord"], etc.) - The updated CSS file is written back
Requirements for the fast path:
styleFilesmust include a.cssfile in the mapping config- CSS variables must use the RGB channel format:
--color-token-name: R G B; - Multiple theme blocks are supported — all occurrences are updated in one pass
Non-color changes (text content, font size, font weight, etc.) are still delegated to Claude CLI with a focused diff-based prompt.
// Example: enable the fast path by adding styleFiles
{
"id": "my-app",
"penFile": "./design.pen",
"codeDir": "./src",
"codeGlobs": ["**/*.tsx", "**/*.css"],
"direction": "both",
"styleFiles": ["app/globals.css", "tailwind.config.js"]
}Changes are detected by comparing SHA-256 hashes of files before and after Claude runs. This is more reliable than parsing Claude's natural language output.
Token usage is parsed from Claude CLI verbose output and accumulated per session. If cumulative spend reaches maxBudgetUsd, further sync operations are blocked.
When a sync writes files (e.g., pen-to-code writes code files), the watcher would normally detect those writes and trigger a reverse sync. This is prevented by:
- A grace period (
debounceMs + 500ms) that keeps the lock held after sync completes - Direction-aware trigger suppression that ignores reverse-direction echoes
When both the .pen file and code files have changed since the last sync:
- prompt — Interactive: asks the user to choose a resolution
- pen-wins — Design takes priority, overwrites code
- code-wins — Code takes priority, overwrites design
- auto-merge — Claude attempts to merge both sides
By default, pencil-sync reads .pen files directly from disk and delegates code edits to the Claude CLI using standard file tools (Edit, Write, Read, Glob, Grep). Enabling the Pencil MCP server gives the Claude subprocess direct, structured access to the .pen file — enabling richer code-to-design sync (reading node IDs, updating design properties, taking screenshots) without raw file parsing.
Without MCP (default)
Claude subprocess
└── file tools only (Edit, Write, Read, Glob, Grep)
└── reads .pen file as raw JSON snapshot
With MCP enabled
Claude subprocess
└── file tools + Pencil MCP tools
mcp__pencil__batch_get — read nodes by ID or pattern
mcp__pencil__batch_design — insert / update / delete nodes
mcp__pencil__set_variables — update design variables / themes
mcp__pencil__get_screenshot — visual validation
└── .pen contents accessed via encrypted MCP protocol (not raw file read)
-
Install and configure the Pencil MCP server.
-
Create an MCP config file (e.g.
mcp.json):
{
"mcpServers": {
"pencil": {
"command": "npx",
"args": ["-y", "@pencil/mcp-server"]
}
}
}- Add
mcpConfigPathto yourpencil-sync.config.json:
{
"settings": {
"model": "claude-sonnet-4-6",
"maxBudgetUsd": 0.5,
"mcpConfigPath": "./mcp.json"
}
}- Run a one-time code-to-design sync with MCP enabled:
pencil-sync sync -d code-to-pen -c ./pencil-sync.config.json- Verify MCP tool usage in logs (look for
mcp__pencil__tool calls instead of raw.penJSON writes):
DEBUG=pencil-sync:* pencil-sync sync -d code-to-pen -c ./pencil-sync.config.json- Run watcher mode for ongoing sync:
pencil-sync watch -c ./pencil-sync.config.json- Validate state after a few edits:
pencil-sync status -c ./pencil-sync.config.json| Direction | Without MCP | With MCP |
|---|---|---|
| pen-to-code | Reads .pen snapshot → color fast path or Claude file edits |
Same (snapshot read is local) |
| code-to-pen | Claude edits .pen as raw JSON |
Claude uses batch_design / set_variables to write structured updates |
| conflict auto-merge | Claude reasons over both sides via file tools | Claude can visually verify via get_screenshot |
MCP is most impactful for code-to-pen and auto-merge — the directions that need to write back to the design file.
.pen files are encrypted. The Pencil MCP server is the only supported way to read or write their contents. If mcpConfigPath is not set, pencil-sync falls back to treating the .pen file as a JSON snapshot (works for design-to-code; code-to-pen writes may be unreliable).
npm run dev # TypeScript watch mode
npm test # Run tests
npm run test:watch # Run tests in watch mode
npm run build # Build for productionThe container runs as the unprivileged node user (UID 1000). On Linux hosts, ensure the mounted project directory is owned by UID 1000:
# Linux: set ownership before running
sudo chown -R 1000:1000 /path/to/my-project
docker build -t pencil-sync .
docker run -v $(pwd):/project pencil-sync watch --config /project/pencil-sync.config.jsonsrc/
index.ts CLI entry point (commander)
sync-engine.ts Orchestrates sync: locks, conflicts, direction routing, budget
pen-to-code.ts Design -> code sync with filesystem diffing
code-to-pen.ts Code -> design sync with hash diffing
claude-runner.ts Spawns Claude CLI, parses token usage
lock-manager.ts Per-mapping mutex with grace period and loop prevention
state-store.ts SHA-256 hashes persisted to JSON, file collection, diffing
conflict-detector.ts Detects when both sides changed since last sync
prompt-builder.ts Loads markdown templates, fills placeholders
config.ts Config loading, framework/styling auto-detection
watcher.ts Chokidar file watching with debounced triggers
logger.ts Colored timestamped logging
__tests__/ 312 tests across 19 test files (vitest)
prompts/
pen-to-code.md Template for design-to-code prompts
code-to-pen.md Template for code-to-design prompts
conflict-resolve.md Template for conflict resolution prompts
pencil.dev, claude code, design to code, code to design, .pen files, bidirectional sync, AI coding, vibe coding, design sync, MCP, Anthropic, frontend tooling
{ "version": 1, "mappings": [ { "id": "my-app", "penFile": "./design.pen", "codeDir": "./frontend", "codeGlobs": ["components/**/*.tsx", "app/**/*.tsx", "app/**/*.css"], "framework": "nextjs", "styling": "tailwind", "direction": "both" } ], "settings": { "model": "claude-sonnet-4-6", "maxBudgetUsd": 0.5, "conflictStrategy": "prompt" } }