An agentic harness for automated React→Vue 3 SFC translation, powered by LangGraph and Claude Code.
Vuemorphic drives a two-phase pipeline that reads a React JSX codebase, analyzes its structure, and produces idiomatic Vue 3 Single File Components — one component at a time, in dependency order, verified at every step.
Built to port Flora CAD — a native-plants landscape design tool — from a React prototype to a Vue 3 production app.
| Phase | What it does |
|---|---|
| A — Analysis | JSX AST extraction, idiom detection, Vue skeleton generation, SQLite import, topological sort |
| B — Translation | LangGraph loop: pick → prompt → claude --print → verify → commit or queue for review |
flowchart LR
subgraph phaseA ["Phase A — Analysis"]
direction TB
jsxFiles[".jsx source files"]
astExtract["ts-morph AST<br>extraction"]
idiomDetect["idiom detection"]
skeleton["Vue SFC skeleton<br>generation"]
dbImport["JSON → SQLite<br>manifest import"]
topoSort["topological sort<br>by dependency"]
jsxFiles --> astExtract --> idiomDetect --> skeleton --> dbImport --> topoSort
end
manifest[("vuemorphic.db<br>+ Vue skeletons")]
subgraph phaseB ["Phase B — Translation"]
direction TB
pickNode["pick next node<br>(haiku, topo order)"]
buildCtx["build context<br>React src + Vue deps + idioms"]
claudeCall["claude --print --no-tools<br>(haiku by default)"]
verifySnip["verify<br>remnant · postfilter · compile · vue-tsc"]
gitCommit["git commit<br>feat(convert): NodeId"]
pickNode --> buildCtx --> claudeCall --> verifySnip
verifySnip -->|PASS| gitCommit
verifySnip -->|FAIL| review["queue for review<br>---BLOCKED--- form"]
end
snippets[/"corpora/claude-design-vue/<br>(git repo, one commit per component)"/]
phaseA ==> manifest
manifest ==> phaseB
phaseB ==> snippets
classDef io fill:#fed7aa,stroke:#c2410c,color:#374151
classDef ai fill:#ddd6fe,stroke:#6d28d9,color:#374151
classDef success fill:#a7f3d0,stroke:#047857,color:#374151
classDef review fill:#fecaca,stroke:#b91c1c,color:#374151
class manifest,snippets io
class claudeCall ai
class gitCommit success
class review review
Five real before/after pairs are in examples/ — a simple control, a complex drag control, a layout screen, and Pill (3 visual variants).
A typical conversion. Input (React JSX):
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}Output (Vue 3 SFC, auto-generated):
<template>
<TweakRow :label="label" :value="`${value}${unit}`">
<input
type="range"
class="twk-slider"
:min="min" :max="max" :step="step" :value="value"
@change="(e) => emit('change', Number((e.target as HTMLInputElement).value))"
/>
</TweakRow>
</template>
<script setup lang="ts">
import TweakRow from './TweakRow.vue'
interface TweakSliderProps {
label: string
value: number
min?: number
max?: number
step?: number
unit?: string
}
const props = withDefaults(defineProps<TweakSliderProps>(), { min: 0, max: 100, step: 1, unit: '' })
const emit = defineEmits<{ change: [value: number] }>()
</script>The agent: inferred onChange → emit('change', ...), translated className → class, typed e.target as HTMLInputElement, converted prop defaults to withDefaults, and typed the defineEmits signature — all without being told to.
Across three real Claude Design projects (produced with Claude Artifacts):
| Project | Components | Auto-converted | Manual fixes | Pass rate |
|---|---|---|---|---|
| argus (X-Plane Sim HiFi) | 46 | 41 (89%) | 5 (11%) | 100% |
| jj-canvas (Jujutsu VCS UI) | 30 | 28 (93%) | 2 (7%) | 100% |
| signal-canvas (Design canvas) | 43 | ~40 (92%) | ~3 (8%) | 100% |
All 119 resulting .vue files pass vue-tsc --noEmit with no errors.
Pixel comparison vs. original (Playwright screenshot diff, after fixing 5 Vue 3.5 rendering bugs found during conversion):
| Screen | Before | After |
|---|---|---|
| signal-canvas mf-main-light | 68.9% diff | 24.1% diff |
| signal-canvas mf-main-dark | 55.9% diff | 16.2% diff |
| argus v2-main | 30.4% diff | 17.7% diff |
Remaining diff is primarily SVG <defs> pattern fills (Chrome renders them black before Vue's reactive updates propagate — not fixable with the current approach) and font rendering noise.
Vue 3.5 bugs discovered during conversion (documented in the prompt template so the agent avoids them):
- Props destructuring (
const { w } = props) loses reactivity — values evaluate as NaN - Numeric CSS values need explicit
"px"suffix —:style="{ left: 812 }"is silently ignored - SVG
<defs>reactive bindings (gradients, patterns) aren't applied by Chrome at first paint fillOpacity/strokeOpacitycamelCase props are ignored — use:style="{ fillOpacity: 0.5 }"- SVG presentation attributes need kebab-case — Vue doesn't auto-convert
strokeWidth→stroke-width
You'll need to bring your own React corpus. Configure vuemorphic.config.json to point source_repo at your React source directory and target_repo at where you want the Vue output. See the config file for all options.
All runtime output (databases, manifests, snippets, review queue) goes into temp/ automatically — that folder is gitignored.
# Install (requires uv)
uv sync
# Phase A: extract AST, detect idioms, generate Vue skeletons, import to SQLite
vuemorphic phase-a --heuristic-tiers
# Phase B: translate all nodes in topological order (parallel, 10 workers)
vuemorphic phase-b
# Translate first 3 nodes only (for testing)
vuemorphic phase-b --max-nodes 3
# Inspect blocked nodes (failed with ---BLOCKED--- form)
vuemorphic blocked
# Manually escalate a blocked node to sonnet tier
vuemorphic escalate NodeId --tier sonnetPhase B supports two backends — set backend in vuemorphic.config.json:
| Backend | Config | Requirement |
|---|---|---|
"claude" (default) |
"start_tier": "haiku" |
Claude Max subscription |
"anthropic-api" |
"start_tier": "haiku" |
ANTHROPIC_API_KEY env var |
An Ollama backend is implemented ("backend": "ollama") but has not been run end-to-end — treat it as experimental and expect rough edges.
export ANTHROPIC_API_KEY=sk-ant-...
# Set backend in vuemorphic.config.json
{ "backend": "anthropic-api", "start_tier": "haiku" }
vuemorphic phase-bvuemorphic/
├── src/vuemorphic/
│ ├── agents/ # Prompt assembly (context.py) and Claude invocation (invoke.py)
│ │ └── prompt_template.py # Conversion prompt template (triple-quoted, easy to edit)
│ ├── analysis/ # Phase A: component_contracts.py extracts Vue-ready contracts
│ ├── graph/ # LangGraph StateGraph (nodes.py, graph.py, state.py)
│ ├── models/ # SQLite manifest (manifest.py, db.py) and ComponentContract
│ ├── skeleton/ # Vue SFC skeleton builder (script, template, style sections)
│ ├── verification/ # 5-tier Vue verification pipeline (verify.py)
│ └── cli.py # Typer CLI entry point
├── phase_a_scripts/ # ts-morph JSX scripts (A1 AST extraction, A2 idiom detection)
├── corpora/ # Source React codebase + output Vue project (gitignored locally)
├── docs/ # Architecture docs and research
├── idiom_dictionary.md # React→Vue idiom guidance injected into prompts
└── vuemorphic.config.json # Paths, model tiers, parallelism config
Every React component in the source codebase becomes a node in vuemorphic.db. Nodes are processed in topological order: by the time a component is translated, all its dependencies are already in Vue.
For each node, Phase B:
- Assembles a prompt with the React JSX source, Vue skeleton, converted dependency
.vuefiles, and relevant idiom guidance - Calls
claude --print --no-toolsas a subprocess (Claude Max subscription auth — no API key needed) - Verifies the output through 5 tiers: React remnant check → postfilter →
@vue/compiler-sfcparse →vue-tsc --noEmit→ PASS - On PASS:
git committo the Vue project repo with the agent's summary as the commit message - On FAIL: queues for human review with the agent's structured
---BLOCKED---diagnosis form
stateDiagram-v2
direction LR
[*] --> pick_next_node
state "pick_next_node<br>claim next eligible node (haiku)" as pick_next_node
state "build_context<br>React src + Vue deps + idiom hints" as build_context
state "invoke_agent<br>claude --print --no-tools" as invoke_agent
state "verify<br>remnant · postfilter · compile · vue-tsc" as verify
state "requeue_node<br>cascade error in other file — retry later" as requeue_node
state "update_manifest<br>git commit, mark CONVERTED" as update_manifest
state "queue_for_review<br>BLOCKED form recorded" as queue_for_review
pick_next_node --> build_context : nodes remain
pick_next_node --> [*] : all done or hard-stopped
build_context --> invoke_agent
invoke_agent --> verify
verify --> update_manifest : PASS
verify --> requeue_node : CASCADE (errors in other files)
verify --> queue_for_review : FAIL
requeue_node --> pick_next_node
update_manifest --> pick_next_node
queue_for_review --> pick_next_node
pick_next_node:::primary
invoke_agent:::ai
verify:::decision
update_manifest:::success
queue_for_review:::error
requeue_node:::trigger
classDef primary fill:#3b82f6,stroke:#1e3a5f,color:#ffffff
classDef ai fill:#ddd6fe,stroke:#6d28d9,color:#374151
classDef decision fill:#fef3c7,stroke:#b45309,color:#374151
classDef success fill:#a7f3d0,stroke:#047857,color:#374151
classDef error fill:#fecaca,stroke:#b91c1c,color:#374151
classDef trigger fill:#fed7aa,stroke:#c2410c,color:#374151
Phase B runs multiple workers in parallel using git worktrees. Each worker gets an isolated copy of the Vue target project, converts and verifies independently, then cherry-picks its commits back to the main branch. No cascade cross-contamination between workers.
# Set parallelism in vuemorphic.config.json
{ "parallelism": 10, ... }
# Workers create corpora/claude-design-vue-worker-{N}/ automatically
# and clean up after themselvesWhen an agent can't complete a conversion, it fills in a structured ---BLOCKED--- form:
---BLOCKED---
CATEGORY: info_gap | prompt_confusion | tooling | complexity | cascade | unknown
MISSING: what specific information was absent
TRIED: what approaches were attempted
FIX: one concrete change to the harness or prompt that would help
This is stored in the DB and surfaced by vuemorphic blocked. Use vuemorphic escalate to manually promote a node to a higher model tier after reviewing the diagnosis.
-
Scope: Claude Design artifacts. The pipeline was built and tested on React apps generated by Claude's artifact tool (
claude.ai). These tend to be single-file or few-file JSX projects using inline styles — no Tailwind, no styled-components, no complex build setup. Performance on production React codebases is untested. -
SVG
<defs>patterns. Chrome doesn't apply Vue's reactive bindings to<pattern>,<linearGradient>, or<radialGradient>fills before first paint. The workaround (direct:fillbindings instead ofurl(#id)) produces correct behavior but loses the indirection. Canvases with heavy SVG pattern textures will look different until this Chrome behavior changes. -
One haiku attempt per node. With
max_attempts: {haiku: 1}the failure rate on complex layout components is ~10%. Enabling multiple attempts (or escalation to sonnet) drops this to ~2% but costs more. -
No router/state management migration. The tool translates components. It doesn't migrate React Router to Vue Router, React Context to Pinia, or React hooks to composables — that's handled by the idiom dictionary hints, but complex patterns may require manual review.
-
vue-tsc strict mode required. The verification pipeline runs
vue-tsc --noEmit. Projects with lenient TypeScript configs may silently accept incorrect types that stricter projects would catch.
MIT
