zelune 1.4.0 → 1.5.1
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 +24 -0
- package/bin/cli.mjs +215 -31
- package/mcp/server.ts +57 -1
- package/package.json +1 -1
- package/src/canvas/CanvasApp.tsx +89 -4
- package/src/canvas/ScreenArtboard.tsx +1008 -43
- package/src/server/apiMiddleware.ts +185 -12
- package/src/server/astEngine.ts +428 -19
- package/vite.config.ts +91 -24
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
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.1] - 2026-10-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **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).
|
|
9
|
+
- **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`.
|
|
10
|
+
|
|
11
|
+
## [1.5.0] - 2026-10-21
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **On-Canvas Direct Manipulation Suite (`src/canvas/ScreenArtboard.tsx`, `src/canvas/CanvasApp.tsx`, `src/server/astEngine.ts`, `src/server/apiMiddleware.ts`, `mcp/server.ts`)**:
|
|
15
|
+
- **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`.
|
|
16
|
+
- **Inner Corner `borderRadius` Handles**: Added 4 inset circular corner radius handles on selected elements to interactively round corners on the artboard.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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).
|
|
19
|
+
|
|
20
|
+
## [1.4.1] - 2026-10-21
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **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`.
|
|
24
|
+
- **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`.
|
|
25
|
+
- **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`.
|
|
26
|
+
- **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.
|
|
27
|
+
- **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`.
|
|
28
|
+
|
|
5
29
|
## [1.4.0] - 2026-10-21
|
|
6
30
|
|
|
7
31
|
### Added
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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',
|
|
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 "
|
|
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:
|
|
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
|
-
|
|
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
|
|
390
|
+
const viteCliFlags = ['--host', '127.0.0.1', '--port', port];
|
|
221
391
|
if (shouldOpenBrowser) {
|
|
222
|
-
|
|
392
|
+
viteCliFlags.push('--open');
|
|
223
393
|
}
|
|
224
394
|
|
|
225
395
|
console.log(`\n🚀 Zelune — React Native Visual Canvas & MCP`);
|
|
226
|
-
console.log(`📂
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
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
package/src/canvas/CanvasApp.tsx
CHANGED
|
@@ -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', {
|
|
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', {
|
|
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
|
})}
|