zelune 1.5.7 → 1.6.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.
@@ -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,10 +36,10 @@ 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
- zIndex: 5,
42
+ zIndex: 25,
43
43
  }}
44
44
  >
45
45
  <defs>
@@ -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,87 @@ 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
+ if (toBox.x >= fromBox.x) {
103
+ // Left-to-right forward connection in same row
104
+ startX = fromBox.x + fromBox.width;
105
+ startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height * 0.45 + verticalSlot;
106
+ endX = toBox.x;
107
+ endY = toBox.y + ARTBOARD_HEADER_OFFSET + toBox.height * 0.36 + verticalSlot * 0.6;
108
+ const dx = Math.max(85, Math.abs(endX - startX) * 0.45);
109
+ cp1X = startX + dx;
110
+ cp1Y = startY;
111
+ cp2X = endX - dx;
112
+ cp2Y = endY;
113
+ } else {
114
+ // Return loop arched smoothly in the horizontal alley underneath the row
115
+ startX = fromBox.x + fromBox.width * 0.5 + horizontalSlot * 0.5;
116
+ startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height;
117
+ endX = toBox.x + toBox.width * 0.5 - horizontalSlot * 0.5;
118
+ endY = toBox.y + ARTBOARD_HEADER_OFFSET + toBox.height;
119
+ const archDepth = 95 + (idx % 3) * 32;
120
+ cp1X = startX;
121
+ cp1Y = startY + archDepth;
122
+ cp2X = endX;
123
+ cp2Y = endY + archDepth;
124
+ }
125
+ } else if (toBox.y > fromBox.y) {
126
+ // Cross-row downward connection (Bottom of fromBox -> Top of toBox)
127
+ startX = fromBox.x + fromBox.width * 0.5 + horizontalSlot;
105
128
  startY = fromBox.y + ARTBOARD_HEADER_OFFSET + fromBox.height;
106
- endX = toBox.x + toBox.width * 0.5;
129
+ endX = toBox.x + toBox.width * 0.5 + horizontalSlot * 0.6;
130
+ endY = toBox.y + ARTBOARD_HEADER_OFFSET;
131
+ const dy = Math.max(90, Math.abs(endY - startY) * 0.48);
132
+ cp1X = startX;
133
+ cp1Y = startY + dy;
134
+ cp2X = endX;
135
+ cp2Y = endY - dy;
136
+ } else {
137
+ // Cross-row upward return connection (Top of fromBox -> Bottom of toBox)
138
+ startX = fromBox.x + fromBox.width * 0.5 + horizontalSlot;
139
+ startY = fromBox.y + ARTBOARD_HEADER_OFFSET;
140
+ endX = toBox.x + toBox.width * 0.5 + horizontalSlot * 0.6;
107
141
  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;
142
+ const dy = Math.max(90, Math.abs(startY - endY) * 0.48);
143
+ cp1X = startX;
144
+ cp1Y = startY - dy;
145
+ cp2X = endX;
146
+ cp2Y = endY + dy;
113
147
  }
114
148
 
115
- const strokeColor = isHighlighted ? '#34D399' : '#6366F1';
116
- const strokeOpacity = isHighlighted ? 0.95 : 0.55;
149
+ const pathD = `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`;
150
+ // Exact cubic Bezier midpoint at t = 0.5
151
+ const midX = 0.125 * startX + 0.375 * cp1X + 0.375 * cp2X + 0.125 * endX;
152
+ const midY = 0.125 * startY + 0.375 * cp1Y + 0.375 * cp2Y + 0.125 * endY;
153
+
154
+ const strokeColor = useEmeraldWire ? '#10B981' : '#6366F1';
155
+ const strokeOpacity = isHighlighted ? 0.98 : 0.82;
117
156
 
118
157
  return (
119
158
  <g key={edge.id}>
@@ -122,9 +161,9 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
122
161
  <path
123
162
  d={pathD}
124
163
  fill="none"
125
- stroke="#34D399"
126
- strokeWidth={6}
127
- strokeOpacity={0.18}
164
+ stroke={strokeColor}
165
+ strokeWidth={7}
166
+ strokeOpacity={0.22}
128
167
  />
129
168
  )}
130
169
 
@@ -133,11 +172,11 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
133
172
  d={pathD}
134
173
  fill="none"
135
174
  stroke={strokeColor}
136
- strokeWidth={isHighlighted ? 2.25 : 1.75}
175
+ strokeWidth={isHighlighted ? 2.8 : 2.15}
137
176
  strokeOpacity={strokeOpacity}
138
- strokeDasharray={isForward ? undefined : '6 5'}
177
+ strokeDasharray={isReturnOrBackward ? '7 5' : undefined}
139
178
  markerEnd={
140
- isHighlighted ? 'url(#flow-arrow-active)' : 'url(#flow-arrow-default)'
179
+ useEmeraldWire ? 'url(#flow-arrow-active)' : 'url(#flow-arrow-default)'
141
180
  }
142
181
  />
143
182
 
@@ -145,17 +184,17 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
145
184
  <circle
146
185
  cx={startX}
147
186
  cy={startY}
148
- r={4}
187
+ r={4.5}
149
188
  fill="var(--studio-surface-1)"
150
189
  stroke={strokeColor}
151
- strokeWidth={2}
190
+ strokeWidth={2.2}
152
191
  />
153
192
 
154
193
  {/* Interactive Flow Label Pill */}
155
194
  <foreignObject
156
- x={midX - 75}
195
+ x={midX - 85}
157
196
  y={midY - 13}
158
- width={150}
197
+ width={170}
159
198
  height={28}
160
199
  style={{ pointerEvents: 'auto', overflow: 'visible' }}
161
200
  >
@@ -173,8 +212,8 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
173
212
  padding: '3px 9px',
174
213
  borderRadius: 999,
175
214
  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)',
215
+ border: `1px solid ${useEmeraldWire ? 'var(--studio-accent-emerald)' : 'var(--studio-badge-indigo-border)'}`,
216
+ color: useEmeraldWire ? 'var(--studio-badge-emerald-text)' : 'var(--studio-badge-indigo-text)',
178
217
  fontSize: 10,
179
218
  fontWeight: 600,
180
219
  fontFamily: 'JetBrains Mono, monospace',