zelune 1.5.7 → 1.6.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.
@@ -18,6 +18,7 @@ import {
18
18
  Sliders,
19
19
  Sun,
20
20
  Moon,
21
+ Plus,
21
22
  } from 'lucide-react';
22
23
  import { SCREEN_COMPONENTS, SCREEN_ORDER } from '../screens';
23
24
  import { ScreenArtboard } from './ScreenArtboard';
@@ -31,12 +32,18 @@ import type {
31
32
  ScreenMetadata,
32
33
  SelectedElementRef,
33
34
  } from './types';
34
- import type { PatchPayload, ReorderPayload } from '../server/astEngine';
35
+ import type {
36
+ PatchPayload,
37
+ ReorderPayload,
38
+ InsertElementPayload,
39
+ DuplicateElementPayload,
40
+ DeleteElementPayload,
41
+ } from '../server/astEngine';
35
42
 
36
43
  const DEFAULT_WIDTH = 393;
37
44
  const DEFAULT_HEIGHT = 852;
38
- const ARTBOARD_GAP = 130;
39
- const ARTBOARD_ROW_GAP = 140;
45
+ const ARTBOARD_GAP = 160;
46
+ const ARTBOARD_ROW_GAP = 220;
40
47
  const INITIAL_OFFSET_X = 80;
41
48
  const INITIAL_OFFSET_Y = 70;
42
49
  const MIN_ZOOM = 0.08;
@@ -241,6 +248,11 @@ export const CanvasApp: React.FC = () => {
241
248
  }
242
249
  const sorted = sortScreensByOrder(screensJson.screens);
243
250
  setScreens(sorted);
251
+ setActiveScreenId((prev) => {
252
+ if (prev && sorted.some((s) => s.id === prev)) return prev;
253
+ const home = sorted.find((s) => s.id.toLowerCase() === 'home');
254
+ return home ? home.id : (sorted[0]?.id ?? null);
255
+ });
244
256
  if (screensJson.history) {
245
257
  setHistoryState(screensJson.history);
246
258
  }
@@ -412,6 +424,132 @@ export const CanvasApp: React.FC = () => {
412
424
  [fetchScreens]
413
425
  );
414
426
 
427
+ // Surgical AST Component / Section Insertion handler
428
+ const handleInsertElement = useCallback(
429
+ async (payload: InsertElementPayload) => {
430
+ setIsSaving(true);
431
+ setStatusMessage(null);
432
+ try {
433
+ const res = await fetch('/api/insert', {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify(payload),
437
+ });
438
+ const data = (await res.json()) as {
439
+ ok: boolean;
440
+ element?: InspectedElement;
441
+ history?: { undoAvailable: number; redoAvailable: number };
442
+ error?: string;
443
+ };
444
+ if (data.ok && data.element) {
445
+ setInspectedData(data.element);
446
+ if (data.history) setHistoryState(data.history);
447
+ const ownerScreen =
448
+ screens.find((s) => s.filePath === data.element!.file)?.id ??
449
+ activeScreenId ??
450
+ 'Home';
451
+ setSelectedElement({
452
+ screenId: ownerScreen,
453
+ file: data.element.file,
454
+ astPath: data.element.astPath,
455
+ line: data.element.line,
456
+ col: data.element.col,
457
+ tagName: data.element.tagName,
458
+ });
459
+ setStatusMessage(`Added <${data.element.tagName}> to .tsx`);
460
+ void fetchScreens();
461
+ } else if (data.error) {
462
+ setStatusMessage(`Error: ${data.error}`);
463
+ }
464
+ } catch {
465
+ setStatusMessage('Failed to insert AST element');
466
+ } finally {
467
+ setIsSaving(false);
468
+ }
469
+ },
470
+ [fetchScreens, screens, activeScreenId]
471
+ );
472
+
473
+ // Surgical AST Duplicate Element handler
474
+ const handleDuplicateElement = useCallback(
475
+ async (payload: DuplicateElementPayload) => {
476
+ setIsSaving(true);
477
+ setStatusMessage(null);
478
+ try {
479
+ const res = await fetch('/api/duplicate', {
480
+ method: 'POST',
481
+ headers: { 'Content-Type': 'application/json' },
482
+ body: JSON.stringify(payload),
483
+ });
484
+ const data = (await res.json()) as {
485
+ ok: boolean;
486
+ element?: InspectedElement;
487
+ history?: { undoAvailable: number; redoAvailable: number };
488
+ error?: string;
489
+ };
490
+ if (data.ok && data.element) {
491
+ setInspectedData(data.element);
492
+ if (data.history) setHistoryState(data.history);
493
+ setSelectedElement((prev) =>
494
+ prev
495
+ ? {
496
+ ...prev,
497
+ line: data.element!.line,
498
+ col: data.element!.col,
499
+ astPath: data.element!.astPath,
500
+ tagName: data.element!.tagName,
501
+ }
502
+ : null
503
+ );
504
+ setStatusMessage('Duplicated element in .tsx');
505
+ void fetchScreens();
506
+ } else if (data.error) {
507
+ setStatusMessage(`Error: ${data.error}`);
508
+ }
509
+ } catch {
510
+ setStatusMessage('Failed to duplicate AST element');
511
+ } finally {
512
+ setIsSaving(false);
513
+ }
514
+ },
515
+ [fetchScreens]
516
+ );
517
+
518
+ // Surgical AST Delete Element handler
519
+ const handleDeleteElement = useCallback(
520
+ async (payload: DeleteElementPayload) => {
521
+ setIsSaving(true);
522
+ setStatusMessage(null);
523
+ try {
524
+ const res = await fetch('/api/delete', {
525
+ method: 'POST',
526
+ headers: { 'Content-Type': 'application/json' },
527
+ body: JSON.stringify(payload),
528
+ });
529
+ const data = (await res.json()) as {
530
+ ok: boolean;
531
+ deletedAstPath?: string;
532
+ history?: { undoAvailable: number; redoAvailable: number };
533
+ error?: string;
534
+ };
535
+ if (data.ok) {
536
+ if (data.history) setHistoryState(data.history);
537
+ setSelectedElement(null);
538
+ setInspectedData(null);
539
+ setStatusMessage('Deleted element from .tsx');
540
+ void fetchScreens();
541
+ } else if (data.error) {
542
+ setStatusMessage(`Error: ${data.error}`);
543
+ }
544
+ } catch {
545
+ setStatusMessage('Failed to delete AST element');
546
+ } finally {
547
+ setIsSaving(false);
548
+ }
549
+ },
550
+ [fetchScreens]
551
+ );
552
+
415
553
  // Undo & Redo handlers
416
554
  const handleUndo = useCallback(async () => {
417
555
  const res = await fetch('/api/undo', {
@@ -939,6 +1077,32 @@ export const CanvasApp: React.FC = () => {
939
1077
  <GitCommit size={14} style={{ flexShrink: 0 }} />
940
1078
  <span className="zelune-reveal-label">Flow Graph</span>
941
1079
  </button>
1080
+
1081
+ <button
1082
+ type="button"
1083
+ className="zelune-interactive"
1084
+ onClick={() => {
1085
+ setIsRightPanelOpen(true);
1086
+ window.dispatchEvent(new CustomEvent('zelune:open-insert-palette'));
1087
+ }}
1088
+ title="Add Button, Section, Input, or Component to Active Screen"
1089
+ style={{
1090
+ display: 'inline-flex',
1091
+ alignItems: 'center',
1092
+ gap: 5,
1093
+ padding: '6px 10px',
1094
+ borderRadius: 7,
1095
+ border: '1px solid rgba(56, 189, 248, 0.4)',
1096
+ backgroundColor: 'rgba(56, 189, 248, 0.12)',
1097
+ color: colorScheme === 'dark' ? '#38BDF8' : '#0284C7',
1098
+ fontSize: 12,
1099
+ fontWeight: 600,
1100
+ cursor: 'pointer',
1101
+ }}
1102
+ >
1103
+ <Plus size={14} style={{ flexShrink: 0 }} />
1104
+ <span>Add Element</span>
1105
+ </button>
942
1106
  </div>
943
1107
 
944
1108
  {/* Right: Undo/Redo, Viewport Controls, Theme Toggle & Right Inspector Toggle */}
@@ -1466,11 +1630,18 @@ export const CanvasApp: React.FC = () => {
1466
1630
  {/* Right: AST Visual Inspector Panel */}
1467
1631
  <InspectorPanel
1468
1632
  inspected={inspectedData}
1633
+ screens={screens}
1634
+ activeScreenId={activeScreenId}
1469
1635
  isOpen={isRightPanelOpen}
1470
1636
  isLoading={isInspecting}
1471
1637
  isSaving={isSaving}
1472
1638
  statusMessage={statusMessage}
1473
1639
  onPatch={handlePatchElement}
1640
+ onInsert={handleInsertElement}
1641
+ onDuplicate={handleDuplicateElement}
1642
+ onDelete={handleDeleteElement}
1643
+ onReorder={handleReorderElement}
1644
+ onSelectScreen={setActiveScreenId}
1474
1645
  onClearSelection={() => {
1475
1646
  setSelectedElement(null);
1476
1647
  setInspectedData(null);
@@ -36,7 +36,7 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
36
36
  top: 0,
37
37
  left: 0,
38
38
  width: 8000,
39
- height: 4000,
39
+ height: 6000,
40
40
  pointerEvents: 'none',
41
41
  overflow: 'visible',
42
42
  zIndex: 5,
@@ -48,8 +48,8 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
48
48
  viewBox="0 0 10 10"
49
49
  refX="8"
50
50
  refY="5"
51
- markerWidth="6"
52
- markerHeight="6"
51
+ markerWidth="6.5"
52
+ markerHeight="6.5"
53
53
  orient="auto-start-reverse"
54
54
  >
55
55
  <path d="M 0 1.5 L 8.5 5 L 0 8.5 z" fill="#6366F1" />
@@ -63,7 +63,7 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
63
63
  markerHeight="7"
64
64
  orient="auto-start-reverse"
65
65
  >
66
- <path d="M 0 1.5 L 8.5 5 L 0 8.5 z" fill="#34D399" />
66
+ <path d="M 0 1.5 L 8.5 5 L 0 8.5 z" fill="#10B981" />
67
67
  </marker>
68
68
  </defs>
69
69
 
@@ -72,48 +72,90 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
72
72
  const toBox = layout[edge.toScreen];
73
73
  if (!fromBox || !toBox) return null;
74
74
 
75
- const isForward = toBox.x >= fromBox.x;
75
+ const sameRow = Math.abs(toBox.y - fromBox.y) < 180;
76
+ const isExplicitReturn =
77
+ edge.id.includes(':return') || edge.triggerLabel.startsWith('Back to ');
78
+ const isReturnOrBackward =
79
+ isExplicitReturn || (sameRow ? toBox.x < fromBox.x : toBox.y < fromBox.y);
80
+
76
81
  const isHighlighted =
77
82
  activeScreenId === edge.fromScreen ||
78
83
  highlightedScreenId === edge.toScreen ||
79
84
  activeScreenId === edge.toScreen;
80
85
 
81
- // Vertical staggering so multiple arrows don't overlap
82
- const verticalSlot = ((idx % 4) - 1.5) * 56;
86
+ // Emerald/Green wires for Return/Back flows AND Active/Highlighted flows; Blue/Indigo wires for Forward flows
87
+ const useEmeraldWire = isHighlighted || isReturnOrBackward;
88
+
89
+ const verticalSlot = ((idx % 4) - 1.5) * 52;
90
+ const horizontalSlot = ((idx % 5) - 2) * 38;
83
91
 
84
92
  let startX: number;
85
93
  let startY: number;
94
+ let cp1X: number;
95
+ let cp1Y: number;
96
+ let cp2X: number;
97
+ let cp2Y: number;
86
98
  let endX: number;
87
99
  let endY: number;
88
- let pathD: string;
89
- let midX: number;
90
- let midY: number;
91
-
92
- if (isForward) {
93
- startX = fromBox.x + fromBox.width;
94
- startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height * 0.45 + verticalSlot;
95
- endX = toBox.x;
96
- endY = toBox.y + ARTBOARD_HEADER_OFFSET + toBox.height * 0.35 + verticalSlot * 0.6;
97
-
98
- const dx = Math.max(80, Math.abs(endX - startX) * 0.45);
99
- pathD = `M ${startX} ${startY} C ${startX + dx} ${startY}, ${endX - dx} ${endY}, ${endX} ${endY}`;
100
- midX = (startX + endX) / 2;
101
- midY = (startY + endY) / 2;
102
- } else {
103
- // Return loop arched underneath the artboards
104
- startX = fromBox.x + fromBox.width * 0.5;
100
+
101
+ if (sameRow) {
102
+ const horizontalGap = toBox.x - (fromBox.x + fromBox.width);
103
+ const isAdjacentForward = toBox.x > fromBox.x && horizontalGap <= 240;
104
+
105
+ if (isAdjacentForward) {
106
+ // Directly adjacent neighbors in the same row: connect across the empty vertical alley
107
+ startX = fromBox.x + fromBox.width;
108
+ startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height * 0.45 + verticalSlot;
109
+ endX = toBox.x;
110
+ endY = toBox.y + ARTBOARD_HEADER_OFFSET + toBox.height * 0.36 + verticalSlot * 0.6;
111
+ const dx = Math.max(60, Math.abs(endX - startX) * 0.45);
112
+ cp1X = startX + dx;
113
+ cp1Y = startY;
114
+ cp2X = endX - dx;
115
+ cp2Y = endY;
116
+ } else {
117
+ // Non-adjacent or return in same row: arch cleanly inside the empty horizontal corridor below the row
118
+ startX = fromBox.x + fromBox.width * 0.5 + horizontalSlot * 0.5;
119
+ startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height;
120
+ endX = toBox.x + toBox.width * 0.5 - horizontalSlot * 0.5;
121
+ endY = toBox.y + ARTBOARD_HEADER_OFFSET + toBox.height;
122
+ const archDepth = 58 + (idx % 4) * 26;
123
+ cp1X = startX;
124
+ cp1Y = startY + archDepth;
125
+ cp2X = endX;
126
+ cp2Y = endY + archDepth;
127
+ }
128
+ } else if (toBox.y > fromBox.y) {
129
+ // Cross-row downward connection through the horizontal corridor between rows
130
+ startX = fromBox.x + fromBox.width * 0.5 + horizontalSlot;
105
131
  startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height;
106
- endX = toBox.x + toBox.width * 0.5;
132
+ endX = toBox.x + toBox.width * 0.5 + horizontalSlot * 0.6;
133
+ endY = toBox.y;
134
+ const dy = Math.max(70, Math.min(140, Math.abs(endY - startY) * 0.45));
135
+ cp1X = startX;
136
+ cp1Y = startY + dy;
137
+ cp2X = endX;
138
+ cp2Y = endY - dy;
139
+ } else {
140
+ // Cross-row upward return connection through the horizontal corridor between rows
141
+ startX = fromBox.x + fromBox.width * 0.5 + horizontalSlot;
142
+ startY = fromBox.y;
143
+ endX = toBox.x + toBox.width * 0.5 + horizontalSlot * 0.6;
107
144
  endY = toBox.y + ARTBOARD_HEADER_OFFSET + toBox.height;
108
-
109
- const archDepth = 110 + (idx % 3) * 35;
110
- pathD = `M ${startX} ${startY} C ${startX} ${startY + archDepth}, ${endX} ${endY + archDepth}, ${endX} ${endY}`;
111
- midX = (startX + endX) / 2;
112
- midY = Math.max(startY, endY) + archDepth * 0.72;
145
+ const dy = Math.max(70, Math.min(140, Math.abs(startY - endY) * 0.45));
146
+ cp1X = startX;
147
+ cp1Y = startY - dy;
148
+ cp2X = endX;
149
+ cp2Y = endY + dy;
113
150
  }
114
151
 
115
- const strokeColor = isHighlighted ? '#34D399' : '#6366F1';
116
- const strokeOpacity = isHighlighted ? 0.95 : 0.55;
152
+ const pathD = `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`;
153
+ // Exact cubic Bezier midpoint at t = 0.5
154
+ const midX = 0.125 * startX + 0.375 * cp1X + 0.375 * cp2X + 0.125 * endX;
155
+ const midY = 0.125 * startY + 0.375 * cp1Y + 0.375 * cp2Y + 0.125 * endY;
156
+
157
+ const strokeColor = useEmeraldWire ? '#10B981' : '#6366F1';
158
+ const strokeOpacity = isHighlighted ? 0.98 : 0.82;
117
159
 
118
160
  return (
119
161
  <g key={edge.id}>
@@ -122,9 +164,9 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
122
164
  <path
123
165
  d={pathD}
124
166
  fill="none"
125
- stroke="#34D399"
126
- strokeWidth={6}
127
- strokeOpacity={0.18}
167
+ stroke={strokeColor}
168
+ strokeWidth={7}
169
+ strokeOpacity={0.22}
128
170
  />
129
171
  )}
130
172
 
@@ -133,11 +175,11 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
133
175
  d={pathD}
134
176
  fill="none"
135
177
  stroke={strokeColor}
136
- strokeWidth={isHighlighted ? 2.25 : 1.75}
178
+ strokeWidth={isHighlighted ? 2.8 : 2.15}
137
179
  strokeOpacity={strokeOpacity}
138
- strokeDasharray={isForward ? undefined : '6 5'}
180
+ strokeDasharray={isReturnOrBackward ? '7 5' : undefined}
139
181
  markerEnd={
140
- isHighlighted ? 'url(#flow-arrow-active)' : 'url(#flow-arrow-default)'
182
+ useEmeraldWire ? 'url(#flow-arrow-active)' : 'url(#flow-arrow-default)'
141
183
  }
142
184
  />
143
185
 
@@ -145,17 +187,17 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
145
187
  <circle
146
188
  cx={startX}
147
189
  cy={startY}
148
- r={4}
190
+ r={4.5}
149
191
  fill="var(--studio-surface-1)"
150
192
  stroke={strokeColor}
151
- strokeWidth={2}
193
+ strokeWidth={2.2}
152
194
  />
153
195
 
154
196
  {/* Interactive Flow Label Pill */}
155
197
  <foreignObject
156
- x={midX - 75}
198
+ x={midX - 85}
157
199
  y={midY - 13}
158
- width={150}
200
+ width={170}
159
201
  height={28}
160
202
  style={{ pointerEvents: 'auto', overflow: 'visible' }}
161
203
  >
@@ -173,8 +215,8 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
173
215
  padding: '3px 9px',
174
216
  borderRadius: 999,
175
217
  backgroundColor: 'var(--studio-surface-1)',
176
- border: `1px solid ${isHighlighted ? 'var(--studio-accent-emerald)' : 'var(--studio-badge-indigo-border)'}`,
177
- color: isHighlighted ? 'var(--studio-badge-emerald-text)' : 'var(--studio-badge-indigo-text)',
218
+ border: `1px solid ${useEmeraldWire ? 'var(--studio-accent-emerald)' : 'var(--studio-badge-indigo-border)'}`,
219
+ color: useEmeraldWire ? 'var(--studio-badge-emerald-text)' : 'var(--studio-badge-indigo-text)',
178
220
  fontSize: 10,
179
221
  fontWeight: 600,
180
222
  fontFamily: 'JetBrains Mono, monospace',