zelune 1.4.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to the **Zelune — React Native Visual Canvas & MCP** project are documented in this file.
4
4
 
5
+ ## [1.5.2] - 2026-10-21
6
+
7
+ ### Changed
8
+ - **npm Package Documentation (`README.md`)**: Simplified installation and MCP configuration snippets on npm to use `npm install -g zelune` and `npx zelune` directly instead of the GitHub repository URL.
9
+
10
+ ## [1.5.1] - 2026-10-21
11
+
12
+ ### Fixed
13
+ - **Nested Mobile Project / Monorepo Auto-Discovery (`src/server/astEngine.ts`, `bin/cli.mjs`, `vite.config.ts`)**: Added `findMobileAppRoot` so running `zelune` or `zelune init` in a top-level parent or monorepo directory automatically scans up to 2 levels deep (`mobile/`, `apps/mobile/`, etc.) to locate the React Native / Expo app directory (`app.json`, `package.json`, and `.tsx`/`.jsx` screens).
14
+ - **Local `npm install zelune` (`node_modules/zelune`) Execution & Dependency Resolution (`vite.config.ts`, `bin/cli.mjs`)**: Configured `esbuild: { include: /\.(tsx|ts|jsx)$/, exclude: [] }` so Vite transpiles Zelune's studio `.tsx` files even when installed inside `node_modules/zelune`, resolved `react`, `react-dom`, `react-native-web`, and `lucide-react` via `createRequire(import.meta.url)` so external mobile screens do not need `react-native-web` installed locally, and resolved `vite/bin/vite.js` directly in `bin/cli.mjs`.
15
+
16
+ ## [1.5.0] - 2026-10-21
17
+
18
+ ### Added
19
+ - **On-Canvas Direct Manipulation Suite (`src/canvas/ScreenArtboard.tsx`, `src/canvas/CanvasApp.tsx`, `src/server/astEngine.ts`, `src/server/apiMiddleware.ts`, `mcp/server.ts`)**:
20
+ - **8-Point Resize Handles**: Added corner and edge resize handles (`nw`, `n`, `ne`, `e`, `se`, `s`, `sw`, `w`) on selected artboard elements with 60fps direct DOM preview, live `W × H` HUD badge, automatic `'100%'` responsive width snapping at parent edges, and surgical write-back to `StyleSheet.create`.
21
+ - **Inner Corner `borderRadius` Handles**: Added 4 inset circular corner radius handles on selected elements to interactively round corners on the artboard.
22
+ - **Figma Auto-Layout Hybrid Move & Reorder (`reorderElement` / `POST /api/reorder` / `reorder_screen_element`)**: Added drag-and-drop Flexbox sibling reordering with a glowing insertion line indicator, 1-click `↑`/`↓` reorder buttons on the selection pill, a 1-click **Pin/Float (`position: 'absolute'`)** toggle (plus `Option/Alt + Drag` freeform X/Y dragging with artboard center snap guides and `Shift + Drag` margin nudging), and keyboard arrow key (`↑ ↓ ← →` / `Shift + Arrow`) nudging.
23
+ - **Double-Click Inline `<Text>` Editing**: Added direct on-artboard inline text editing when double-clicking any editable `<Text>` node (or clicking the `Type` icon on the selection toolbar).
24
+
25
+ ## [1.4.1] - 2026-10-21
26
+
27
+ ### Fixed
28
+ - **JSX / TypeScript Code Injection Prevention (`src/server/astEngine.ts`)**: Added `createSafeJsxTextChild` in `patchElement` so `<Text>` copy updates containing JSX control characters (`{`, `}`, `<`, `>`), newlines, or leading/trailing whitespace are wrapped as `t.jsxExpressionContainer(t.stringLiteral(payload.text))` rather than raw `t.jsxText()`, preventing arbitrary JSX expression or tag injection into `.tsx`/`.jsx` source files while maintaining lossless round-tripping in `inspectJsxTextChildren`.
29
+ - **Local API CSRF & DNS Rebinding Protection (`src/server/apiMiddleware.ts`, `src/canvas/CanvasApp.tsx`, `vite.config.ts`)**: Added `verifyApiRequestSecurity` and `isLoopbackHostHeader` to block non-loopback `Host` headers (`127.0.0.1`, `localhost`, `[::1]`), reject cross-site `Sec-Fetch-Site` and non-loopback `Origin` headers, require `Content-Type: application/json` on all `POST /api/*` routes, and set `server.cors: false` and `server.allowedHosts` in `vite.config.ts`.
30
+ - **Canonical Realpath & Symlink Traversal Guard (`src/server/astEngine.ts`)**: Hardened `resolveSafeScreenPath` using `fs.realpathSync` and `fs.lstatSync` to block symbolic links inside `src/screens/`, `screens/`, `app/`, and `src/app/` from escaping outside allowed screen directories, and added prototype-pollution/type guards (`__proto__`, `constructor`, `prototype`, `NaN`, `Infinity`) in `upsertObjectProperty`.
31
+ - **Bounded Request Body Stream & Layout Schema Validation (`src/server/apiMiddleware.ts`)**: Updated `readJsonBody` to track byte length and immediately call `req.destroy()` when payloads exceed `1,000,000` bytes (preventing stream heap exhaustion), and added `sanitizeLayoutPayload` to validate `.canvas-layout.json` screen coordinate schemas.
32
+ - **DOM Selector Escaping, `tsconfig.json` Alias Confinement & CLI Port Validation (`src/canvas/ScreenArtboard.tsx`, `vite.config.ts`, `bin/cli.mjs`)**: Added `CSS.escape` when querying `data-agy-ast-path` in `ScreenArtboard.tsx`, replaced the naive comment regex in `vite.config.ts` with a string-aware JSONC comment stripper while confining `tsconfig.json` aliases to the project root and protecting core studio packages, and added integer range validation (`1`–`65535`) for `--port` in `bin/cli.mjs`.
33
+
5
34
  ## [1.4.0] - 2026-10-21
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -2,21 +2,19 @@
2
2
 
3
3
  **Zelune** is a **Figma-style multi-screen visual canvas** and **bi-directional Babel AST sync MCP server** for React Native & Expo applications in **Google Antigravity**, Cursor, and Claude Code.
4
4
 
5
- View all of your mobile screens side-by-side on clean `393 × 852` rectangular artboards in the browser, inspect any `<View>`, `<Text>`, or `<TouchableOpacity>` element, and edit copy or `StyleSheet.create` styles with instant write-back to your `.tsx` source code.
5
+ View all of your mobile screens side-by-side on clean `393 × 852` rectangular artboards in the browser, inspect any `<View>`, `<Text>`, or `<TouchableOpacity>` element, and resize, reorder, or edit copy and `StyleSheet.create` styles with instant write-back to your `.tsx` source code.
6
6
 
7
7
  ---
8
8
 
9
9
  ## Global Installation & Effortless Project Setup
10
10
 
11
- ### 1. Install Globally Once (or via `npm link` locally)
11
+ ### 1. Install Globally Once
12
12
  ```bash
13
- npm install -g github:woodfur/antigravity-rn-canvas
14
- # Or once published to npm:
15
13
  npm install -g zelune
16
14
  ```
17
15
 
18
16
  ### 2. Initialize & Launch in Any React Native / Expo Project
19
- Inside any React Native or Expo project folder:
17
+ Inside any React Native or Expo project folder (or a parent monorepo directory):
20
18
 
21
19
  ```bash
22
20
  cd /path/to/your/react-native-app
@@ -30,14 +28,8 @@ zelune
30
28
 
31
29
  You can also run it without installing globally:
32
30
  ```bash
33
- npx github:woodfur/antigravity-rn-canvas
34
- ```
35
-
36
- ### Option B: Install the Zelune AI Agent Skill
37
- Install the Zelune skill (`skills/rn-canvas/SKILL.md`), design system (`DESIGN.md`, `PRODUCT.md`), and AST rules (`rules/AGENTS.md`) into your AI coding agent:
38
-
39
- ```bash
40
- npx skills add woodfur/antigravity-rn-canvas
31
+ npx zelune init
32
+ npx zelune
41
33
  ```
42
34
 
43
35
  ---
@@ -45,14 +37,14 @@ npx skills add woodfur/antigravity-rn-canvas
45
37
  ## Google Antigravity Integration (Zelune MCP Server)
46
38
 
47
39
  ### 1. MCP Server Setup
48
- Add the **Zelune** MCP server to your `mcp_config.json`:
40
+ Running `zelune init` automatically configures `mcp_config.json`, or you can add the **Zelune** MCP server manually:
49
41
 
50
42
  ```json
51
43
  {
52
44
  "mcpServers": {
53
45
  "zelune": {
54
46
  "command": "npx",
55
- "args": ["-y", "github:woodfur/antigravity-rn-canvas", "--mcp"]
47
+ "args": ["-y", "zelune", "--mcp"]
56
48
  }
57
49
  }
58
50
  }
@@ -62,3 +54,4 @@ Add the **Zelune** MCP server to your `mcp_config.json`:
62
54
  - `list_canvas_screens` — Lists all discovered screens (`src/screens/`, `screens/`, or Expo Router `app/`), element counts, and outgoing navigation flows.
63
55
  - `inspect_screen_element` — Returns the resolved `StyleSheet.create` / inline styles, static text copy, and JSX snippet for any element by `file` and `astPath`.
64
56
  - `patch_screen_element` — Surgically patches text copy or style rules in `.tsx` source files while preserving comments and formatting.
57
+ - `reorder_screen_element` — Surgically reorders a React Native JSX element among its Flexbox siblings in the `.tsx` AST.
package/bin/cli.mjs CHANGED
@@ -1,12 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
+ import { createRequire } from 'node:module';
4
5
  import net from 'node:net';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const defaultPackageRoot = path.resolve(__dirname, '..');
11
+ const requireFromCli = createRequire(import.meta.url);
12
+
13
+ const SCREEN_DIR_CANDIDATES = ['src/screens', 'screens', 'app', 'src/app'];
14
+ const IGNORED_SCAN_DIRS = new Set([
15
+ 'node_modules',
16
+ '.git',
17
+ 'dist',
18
+ 'build',
19
+ '.next',
20
+ '.expo',
21
+ '.turbo',
22
+ 'ios',
23
+ 'android',
24
+ 'vendor',
25
+ 'coverage',
26
+ '.agents',
27
+ '.gemini',
28
+ ]);
10
29
 
11
30
  function isHostPortFree(port, host) {
12
31
  return new Promise((resolve) => {
@@ -43,36 +62,175 @@ export async function findAvailablePort(startPort = 5173, maxAttempts = 25) {
43
62
  return startPort;
44
63
  }
45
64
 
65
+ function countScreenFilesInDirectory(screensDir, maxDepth = 4) {
66
+ let count = 0;
67
+ function walk(curr, depth) {
68
+ if (depth > maxDepth) return;
69
+ let entries = [];
70
+ try {
71
+ entries = fs.readdirSync(curr, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ if (entry.name.startsWith('.')) continue;
77
+ if (entry.isDirectory()) {
78
+ if (
79
+ entry.name === 'node_modules' ||
80
+ entry.name === '__tests__' ||
81
+ entry.name === 'components'
82
+ ) {
83
+ continue;
84
+ }
85
+ walk(path.join(curr, entry.name), depth + 1);
86
+ } else if (entry.isFile()) {
87
+ const f = entry.name;
88
+ if (f.startsWith('_') || f.startsWith('+')) continue;
89
+ if (f === 'index.ts' || f === 'index.js') continue;
90
+ if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
91
+ if (f.endsWith('.tsx') || f.endsWith('.jsx')) {
92
+ count++;
93
+ }
94
+ }
95
+ }
96
+ }
97
+ walk(screensDir, 0);
98
+ return count;
99
+ }
100
+
101
+ function hasDirectScreens(dirPath) {
102
+ for (const rel of SCREEN_DIR_CANDIDATES) {
103
+ const full = path.join(dirPath, rel);
104
+ try {
105
+ if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {
106
+ const count = countScreenFilesInDirectory(full);
107
+ if (count > 0) {
108
+ return { found: true, relDir: rel, count };
109
+ }
110
+ }
111
+ } catch {
112
+ // Ignore
113
+ }
114
+ }
115
+ return { found: false, relDir: null, count: 0 };
116
+ }
117
+
118
+ function scoreMobileCandidate(dirPath, screenCount) {
119
+ let score = screenCount;
120
+ const folderName = path.basename(dirPath).toLowerCase();
121
+ if (/mobile|native|expo|app|client/.test(folderName)) {
122
+ score += 10;
123
+ }
124
+ if (fs.existsSync(path.join(dirPath, 'app.json')) || fs.existsSync(path.join(dirPath, 'eas.json'))) {
125
+ score += 25;
126
+ }
127
+ try {
128
+ const pkgPath = path.join(dirPath, 'package.json');
129
+ if (fs.existsSync(pkgPath)) {
130
+ const pkgRaw = fs.readFileSync(pkgPath, 'utf8');
131
+ if (pkgRaw.includes('"react-native"') || pkgRaw.includes('"expo"')) {
132
+ score += 20;
133
+ }
134
+ }
135
+ } catch {
136
+ // Ignore
137
+ }
138
+ return score;
139
+ }
140
+
141
+ export function findMobileAppRoot(rawRoot) {
142
+ const normalizedRoot = path.resolve(rawRoot);
143
+ if (!fs.existsSync(normalizedRoot)) {
144
+ return normalizedRoot;
145
+ }
146
+
147
+ const direct = hasDirectScreens(normalizedRoot);
148
+ if (direct.found) {
149
+ return normalizedRoot;
150
+ }
151
+
152
+ let bestCandidate = null;
153
+ let bestScore = -1;
154
+
155
+ function scanSubdirs(parentDir, depth) {
156
+ if (depth > 2) return;
157
+ let entries = [];
158
+ try {
159
+ entries = fs.readdirSync(parentDir, { withFileTypes: true });
160
+ } catch {
161
+ return;
162
+ }
163
+
164
+ for (const entry of entries) {
165
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
166
+ if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
167
+
168
+ const subPath = path.join(parentDir, entry.name);
169
+ const check = hasDirectScreens(subPath);
170
+ if (check.found) {
171
+ const score = scoreMobileCandidate(subPath, check.count);
172
+ if (score > bestScore) {
173
+ bestScore = score;
174
+ bestCandidate = subPath;
175
+ }
176
+ } else if (depth < 2) {
177
+ scanSubdirs(subPath, depth + 1);
178
+ }
179
+ }
180
+ }
181
+
182
+ scanSubdirs(normalizedRoot, 1);
183
+ return bestCandidate ?? normalizedRoot;
184
+ }
185
+
46
186
  function detectScreensDirectory(targetProject) {
47
- const candidates = ['src/screens', 'screens', 'app', 'src/app'];
48
- for (const rel of candidates) {
49
- const full = path.join(targetProject, rel);
187
+ const mobileRoot = findMobileAppRoot(targetProject);
188
+ // First check directories with .tsx/.jsx screen files
189
+ for (const rel of SCREEN_DIR_CANDIDATES) {
190
+ const full = path.join(mobileRoot, rel);
191
+ if (fs.existsSync(full) && fs.statSync(full).isDirectory() && countScreenFilesInDirectory(full) > 0) {
192
+ const relFromTarget = path.relative(targetProject, full).replace(/\\/g, '/');
193
+ return relFromTarget || rel;
194
+ }
195
+ }
196
+ // Fallback to existing empty candidate directory (e.g. newly initialized project)
197
+ for (const rel of SCREEN_DIR_CANDIDATES) {
198
+ const full = path.join(mobileRoot, rel);
50
199
  if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {
51
- return rel;
200
+ const relFromTarget = path.relative(targetProject, full).replace(/\\/g, '/');
201
+ return relFromTarget || rel;
52
202
  }
53
203
  }
54
204
  return null;
55
205
  }
56
206
 
207
+ function addCanvasScriptToPackageJson(dirPath) {
208
+ const pkgJsonPath = path.join(dirPath, 'package.json');
209
+ if (!fs.existsSync(pkgJsonPath)) return false;
210
+ try {
211
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
212
+ pkg.scripts = pkg.scripts || {};
213
+ if (pkg.name !== 'zelune' && !pkg.scripts.canvas) {
214
+ pkg.scripts.canvas = 'zelune';
215
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
216
+ return true;
217
+ }
218
+ } catch {
219
+ // Ignore malformed package.json
220
+ }
221
+ return false;
222
+ }
223
+
57
224
  export function initProject(targetProject, packageRoot = defaultPackageRoot) {
58
225
  const resolvedTarget = path.resolve(targetProject);
226
+ const mobileRoot = findMobileAppRoot(resolvedTarget);
59
227
  const screensDir = detectScreensDirectory(resolvedTarget);
60
228
 
61
- // 1. Update package.json with "canvas": "zelune" script if present
62
- const pkgJsonPath = path.join(resolvedTarget, 'package.json');
63
- let updatedPackageJson = false;
64
- if (fs.existsSync(pkgJsonPath)) {
65
- try {
66
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
67
- pkg.scripts = pkg.scripts || {};
68
- if (pkg.name !== 'zelune' && !pkg.scripts.canvas) {
69
- pkg.scripts.canvas = 'zelune';
70
- fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
71
- updatedPackageJson = true;
72
- }
73
- } catch {
74
- // Ignore malformed package.json
75
- }
229
+ // 1. Update package.json with "canvas": "zelune" script in target root (and nested mobile root if different)
230
+ let updatedPackageJson = addCanvasScriptToPackageJson(resolvedTarget);
231
+ if (mobileRoot !== resolvedTarget) {
232
+ const updatedNested = addCanvasScriptToPackageJson(mobileRoot);
233
+ updatedPackageJson = updatedPackageJson || updatedNested;
76
234
  }
77
235
 
78
236
  // 2. Create or merge mcp_config.json with the zelune MCP server entry
@@ -91,7 +249,7 @@ export function initProject(targetProject, packageRoot = defaultPackageRoot) {
91
249
  }
92
250
  mcpConfig.mcpServers.zelune = {
93
251
  command: 'npx',
94
- args: ['-y', 'zelune', '--mcp', '--project', resolvedTarget],
252
+ args: ['-y', 'zelune', '--mcp', '--project', mobileRoot],
95
253
  };
96
254
  fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n', 'utf8');
97
255
 
@@ -139,6 +297,7 @@ export function initProject(targetProject, packageRoot = defaultPackageRoot) {
139
297
 
140
298
  return {
141
299
  targetProject: resolvedTarget,
300
+ mobileRoot,
142
301
  screensDir: screensDir ?? 'src/screens (or app/)',
143
302
  updatedPackageJson,
144
303
  mcpConfigPath,
@@ -178,6 +337,9 @@ Usage:
178
337
  if (isInitMode) {
179
338
  const result = initProject(targetProject, defaultPackageRoot);
180
339
  console.log(`\n✨ Zelune initialized in ${result.targetProject}`);
340
+ if (result.mobileRoot !== result.targetProject) {
341
+ console.log(` • Detected nested mobile app: ${result.mobileRoot}`);
342
+ }
181
343
  console.log(` • Screens directory: ${result.screensDir}`);
182
344
  if (result.updatedPackageJson) {
183
345
  console.log(` • Added "canvas": "zelune" script to package.json`);
@@ -187,13 +349,14 @@ Usage:
187
349
  if (result.createdDesignMd || result.createdProductMd) {
188
350
  console.log(` • Created starter DESIGN.md & PRODUCT.md governance docs`);
189
351
  }
190
- console.log(`\n👉 Run "npm run canvas" (or "zelune") to open your screens in the visual canvas!\n`);
352
+ console.log(`\n👉 Run "npx zelune" (or "npm run canvas") to open your screens in the visual canvas!\n`);
191
353
  process.exit(0);
192
354
  }
193
355
 
356
+ const resolvedMobileRoot = findMobileAppRoot(targetProject);
194
357
  const env = {
195
358
  ...process.env,
196
- ANTIGRAVITY_TARGET_PROJECT: targetProject,
359
+ ANTIGRAVITY_TARGET_PROJECT: resolvedMobileRoot,
197
360
  };
198
361
 
199
362
  if (isMcpMode) {
@@ -210,27 +373,48 @@ Usage:
210
373
  const portFlagIdx = args.indexOf('--port');
211
374
  let port;
212
375
  if (portFlagIdx !== -1 && args[portFlagIdx + 1]) {
213
- port = String(args[portFlagIdx + 1]);
376
+ const parsedPort = Number.parseInt(String(args[portFlagIdx + 1]), 10);
377
+ if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
378
+ console.error(
379
+ `Error: Invalid --port value "${args[portFlagIdx + 1]}". Must be an integer between 1 and 65535.`
380
+ );
381
+ process.exit(1);
382
+ }
383
+ port = String(parsedPort);
214
384
  } else {
215
385
  const resolvedPort = await findAvailablePort(5173);
216
386
  port = String(resolvedPort);
217
387
  }
218
388
 
219
389
  const shouldOpenBrowser = !args.includes('--no-open');
220
- const viteArgs = ['vite', '--host', '127.0.0.1', '--port', port];
390
+ const viteCliFlags = ['--host', '127.0.0.1', '--port', port];
221
391
  if (shouldOpenBrowser) {
222
- viteArgs.push('--open');
392
+ viteCliFlags.push('--open');
223
393
  }
224
394
 
225
395
  console.log(`\n🚀 Zelune — React Native Visual Canvas & MCP`);
226
- console.log(`📂 Target Project: ${targetProject}`);
396
+ console.log(`📂 Workspace Root: ${targetProject}`);
397
+ if (resolvedMobileRoot !== targetProject) {
398
+ console.log(`📱 Mobile App Dir: ${resolvedMobileRoot}`);
399
+ }
227
400
  console.log(`🌐 Studio URL: http://127.0.0.1:${port}\n`);
228
401
 
229
- const child = spawn('npx', viteArgs, {
230
- cwd: defaultPackageRoot,
231
- env,
232
- stdio: 'inherit',
233
- });
402
+ let child;
403
+ try {
404
+ const viteBinPath = requireFromCli.resolve('vite/bin/vite.js');
405
+ child = spawn(process.execPath, [viteBinPath, ...viteCliFlags], {
406
+ cwd: defaultPackageRoot,
407
+ env,
408
+ stdio: 'inherit',
409
+ });
410
+ } catch {
411
+ child = spawn('npx', ['vite', ...viteCliFlags], {
412
+ cwd: defaultPackageRoot,
413
+ env,
414
+ stdio: 'inherit',
415
+ });
416
+ }
417
+
234
418
  child.on('exit', (code) => process.exit(code ?? 0));
235
419
  }
236
420
 
package/mcp/server.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  discoverScreens,
11
11
  inspectElement,
12
12
  patchElement,
13
+ reorderElement,
13
14
  type StyleValue,
14
15
  } from '../src/server/astEngine.js';
15
16
 
@@ -19,7 +20,7 @@ const WORKSPACE_ROOT = path.resolve(__dirname, '..');
19
20
  const server = new Server(
20
21
  {
21
22
  name: 'zelune',
22
- version: '1.3.0',
23
+ version: '1.5.0',
23
24
  },
24
25
  {
25
26
  capabilities: {
@@ -86,6 +87,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
86
87
  required: ['file', 'astPath'],
87
88
  },
88
89
  },
90
+ {
91
+ name: 'reorder_screen_element',
92
+ description:
93
+ 'Surgically reorders a React Native JSX element among its Flexbox siblings in the .tsx AST.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ file: {
98
+ type: 'string',
99
+ description: 'Relative path to the screen file (e.g. src/screens/HomeScreen.tsx)',
100
+ },
101
+ astPath: {
102
+ type: 'string',
103
+ description: 'Preorder AST index of the JSX element to move',
104
+ },
105
+ direction: {
106
+ type: 'string',
107
+ enum: ['up', 'down'],
108
+ description: 'Optional relative direction ("up" or "down") among siblings',
109
+ },
110
+ targetAstPath: {
111
+ type: 'string',
112
+ description: 'Optional AST index of the sibling to place before/after',
113
+ },
114
+ placement: {
115
+ type: 'string',
116
+ enum: ['before', 'after'],
117
+ description: 'Whether to insert before or after targetAstPath',
118
+ },
119
+ },
120
+ required: ['file', 'astPath'],
121
+ },
122
+ },
89
123
  ],
90
124
  };
91
125
  });
@@ -126,6 +160,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
126
160
  };
127
161
  }
128
162
 
163
+ if (name === 'reorder_screen_element') {
164
+ const file = String(args?.file ?? '');
165
+ const astPath = String(args?.astPath ?? '0');
166
+ const direction =
167
+ args?.direction === 'up' || args?.direction === 'down' ? args.direction : undefined;
168
+ const targetAstPath =
169
+ typeof args?.targetAstPath === 'string' ? args.targetAstPath : undefined;
170
+ const placement =
171
+ args?.placement === 'before' || args?.placement === 'after' ? args.placement : undefined;
172
+
173
+ const result = await reorderElement(WORKSPACE_ROOT, {
174
+ file,
175
+ astPath,
176
+ direction,
177
+ targetAstPath,
178
+ placement,
179
+ });
180
+ return {
181
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
182
+ };
183
+ }
184
+
129
185
  throw new Error(`Unknown MCP tool: ${name}`);
130
186
  });
131
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zelune",
3
- "version": "1.4.0",
3
+ "version": "1.5.2",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,7 @@ import type {
29
29
  ScreenMetadata,
30
30
  SelectedElementRef,
31
31
  } from './types';
32
- import type { PatchPayload } from '../server/astEngine';
32
+ import type { PatchPayload, ReorderPayload } from '../server/astEngine';
33
33
 
34
34
  const DEFAULT_WIDTH = 393;
35
35
  const DEFAULT_HEIGHT = 852;
@@ -242,9 +242,57 @@ export const CanvasApp: React.FC = () => {
242
242
  [fetchScreens]
243
243
  );
244
244
 
245
+ // Surgical AST Sibling Reorder handler
246
+ const handleReorderElement = useCallback(
247
+ async (payload: ReorderPayload) => {
248
+ setIsSaving(true);
249
+ setStatusMessage(null);
250
+ try {
251
+ const res = await fetch('/api/reorder', {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify(payload),
255
+ });
256
+ const data = (await res.json()) as {
257
+ ok: boolean;
258
+ element?: InspectedElement;
259
+ history?: { undoAvailable: number; redoAvailable: number };
260
+ error?: string;
261
+ };
262
+ if (data.ok && data.element) {
263
+ setInspectedData(data.element);
264
+ if (data.history) setHistoryState(data.history);
265
+ setSelectedElement((prev) =>
266
+ prev
267
+ ? {
268
+ ...prev,
269
+ line: data.element!.line,
270
+ col: data.element!.col,
271
+ astPath: data.element!.astPath,
272
+ }
273
+ : null
274
+ );
275
+ setStatusMessage('Reordered in .tsx');
276
+ void fetchScreens();
277
+ } else if (data.error) {
278
+ setStatusMessage(`Error: ${data.error}`);
279
+ }
280
+ } catch {
281
+ setStatusMessage('Failed to reorder AST element');
282
+ } finally {
283
+ setIsSaving(false);
284
+ }
285
+ },
286
+ [fetchScreens]
287
+ );
288
+
245
289
  // Undo & Redo handlers
246
290
  const handleUndo = useCallback(async () => {
247
- const res = await fetch('/api/undo', { method: 'POST' });
291
+ const res = await fetch('/api/undo', {
292
+ method: 'POST',
293
+ headers: { 'Content-Type': 'application/json' },
294
+ body: '{}',
295
+ });
248
296
  const data = (await res.json()) as {
249
297
  ok: boolean;
250
298
  restoredFile: string | null;
@@ -265,7 +313,11 @@ export const CanvasApp: React.FC = () => {
265
313
  }, [selectedElement, inspectSelectedElement, fetchScreens]);
266
314
 
267
315
  const handleRedo = useCallback(async () => {
268
- const res = await fetch('/api/redo', { method: 'POST' });
316
+ const res = await fetch('/api/redo', {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: '{}',
320
+ });
269
321
  const data = (await res.json()) as {
270
322
  ok: boolean;
271
323
  restoredFile: string | null;
@@ -308,6 +360,36 @@ export const CanvasApp: React.FC = () => {
308
360
  } else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === ']') {
309
361
  e.preventDefault();
310
362
  setIsRightPanelOpen((prev) => !prev);
363
+ } else if (
364
+ !isInputFocused &&
365
+ selectedElement &&
366
+ inspectedData &&
367
+ ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)
368
+ ) {
369
+ e.preventDefault();
370
+ const step = e.shiftKey ? 8 : 1;
371
+ const isAbs = inspectedData.styles.position === 'absolute';
372
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
373
+ const prop = isAbs ? 'top' : 'marginTop';
374
+ const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
375
+ const nextVal = e.key === 'ArrowUp' ? currentVal - step : currentVal + step;
376
+ void handlePatchElement({
377
+ file: selectedElement.file,
378
+ astPath: selectedElement.astPath,
379
+ line: selectedElement.line,
380
+ styles: { [prop]: nextVal },
381
+ });
382
+ } else {
383
+ const prop = isAbs ? 'left' : 'marginLeft';
384
+ const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
385
+ const nextVal = e.key === 'ArrowLeft' ? currentVal - step : currentVal + step;
386
+ void handlePatchElement({
387
+ file: selectedElement.file,
388
+ astPath: selectedElement.astPath,
389
+ line: selectedElement.line,
390
+ styles: { [prop]: nextVal },
391
+ });
392
+ }
311
393
  } else if (e.key === 'Escape') {
312
394
  setSelectedElement(null);
313
395
  setInspectedData(null);
@@ -316,7 +398,7 @@ export const CanvasApp: React.FC = () => {
316
398
 
317
399
  window.addEventListener('keydown', handleKeyDown);
318
400
  return () => window.removeEventListener('keydown', handleKeyDown);
319
- }, [handleUndo, handleRedo]);
401
+ }, [handleUndo, handleRedo, selectedElement, inspectedData, handlePatchElement]);
320
402
 
321
403
  // Lock out whole-page browser pinch-to-zoom & route trackpad pinch to cursor-centered canvas zoom
322
404
  useEffect(() => {
@@ -1171,11 +1253,14 @@ export const CanvasApp: React.FC = () => {
1171
1253
  isActiveScreen={activeScreenId === screen.id}
1172
1254
  isHighlightedScreen={highlightedScreenId === screen.id}
1173
1255
  selectedElement={selectedElement}
1256
+ inspectedElement={inspectedData}
1174
1257
  onSelectElement={handleSelectElement}
1175
1258
  onSelectScreen={setActiveScreenId}
1176
1259
  onFocusScreen={focusOnScreen}
1177
1260
  onMoveArtboard={handleMoveArtboard}
1178
1261
  onNavigateFlow={handleNavigateFlow}
1262
+ onPatchElement={handlePatchElement}
1263
+ onReorderElement={handleReorderElement}
1179
1264
  />
1180
1265
  );
1181
1266
  })}