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.
@@ -1,13 +1,25 @@
1
1
  import React, { useRef, useState, useEffect, useCallback } from 'react';
2
- import { GripHorizontal, Maximize2, AlertTriangle } from 'lucide-react';
2
+ import {
3
+ GripHorizontal,
4
+ Maximize2,
5
+ AlertTriangle,
6
+ ArrowUp,
7
+ ArrowDown,
8
+ Pin,
9
+ PinOff,
10
+ Type,
11
+ Move,
12
+ } from 'lucide-react';
3
13
  import { MockNavigationProvider } from '../runtime/MockNavigationContext';
4
14
  import { ScreenErrorBoundary } from '../runtime/ScreenErrorBoundary';
5
15
  import type {
6
16
  ArtboardPosition,
7
17
  CanvasMode,
18
+ InspectedElement,
8
19
  ScreenMetadata,
9
20
  SelectedElementRef,
10
21
  } from './types';
22
+ import type { PatchPayload, ReorderPayload } from '../server/astEngine';
11
23
 
12
24
  interface ScreenArtboardProps {
13
25
  screen: ScreenMetadata;
@@ -18,11 +30,14 @@ interface ScreenArtboardProps {
18
30
  isActiveScreen: boolean;
19
31
  isHighlightedScreen: boolean;
20
32
  selectedElement: SelectedElementRef | null;
33
+ inspectedElement: InspectedElement | null;
21
34
  onSelectElement: (ref: SelectedElementRef) => void;
22
35
  onSelectScreen: (screenId: string) => void;
23
36
  onFocusScreen: (screenId: string) => void;
24
37
  onMoveArtboard: (screenId: string, nextX: number, nextY: number) => void;
25
38
  onNavigateFlow: (fromScreen: string, toScreen: string) => void;
39
+ onPatchElement: (payload: PatchPayload) => Promise<void>;
40
+ onReorderElement: (payload: ReorderPayload) => Promise<void>;
26
41
  }
27
42
 
28
43
  interface BoxOverlay {
@@ -34,6 +49,19 @@ interface BoxOverlay {
34
49
  astPath: string;
35
50
  }
36
51
 
52
+ interface DropIndicator {
53
+ targetAstPath: string;
54
+ targetTagName: string;
55
+ placement: 'before' | 'after';
56
+ isRow: boolean;
57
+ lineTop: number;
58
+ lineLeft: number;
59
+ lineWidth: number;
60
+ lineHeight: number;
61
+ }
62
+
63
+ type ResizeDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
64
+
37
65
  function findNearestAgyElement(
38
66
  target: EventTarget | null,
39
67
  container: HTMLElement | null
@@ -49,6 +77,12 @@ function findNearestAgyElement(
49
77
  return null;
50
78
  }
51
79
 
80
+ function escapeAstPathSelector(astPath: string): string {
81
+ return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
82
+ ? CSS.escape(astPath)
83
+ : astPath.replace(/["\\]/g, '\\$&');
84
+ }
85
+
52
86
  export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
53
87
  screen,
54
88
  ScreenComponent,
@@ -58,17 +92,54 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
58
92
  isActiveScreen,
59
93
  isHighlightedScreen,
60
94
  selectedElement,
95
+ inspectedElement,
61
96
  onSelectElement,
62
97
  onSelectScreen,
63
98
  onFocusScreen,
64
99
  onMoveArtboard,
65
100
  onNavigateFlow,
101
+ onPatchElement,
102
+ onReorderElement,
66
103
  }) => {
67
104
  const viewportRef = useRef<HTMLDivElement | null>(null);
105
+ const inlineInputRef = useRef<HTMLTextAreaElement | null>(null);
106
+
68
107
  const [hoverBox, setHoverBox] = useState<BoxOverlay | null>(null);
69
108
  const [selectedBox, setSelectedBox] = useState<BoxOverlay | null>(null);
70
109
  const [isDraggingHeader, setIsDraggingHeader] = useState(false);
71
110
 
111
+ // Direct On-Canvas Manipulation States
112
+ const [activeGizmoAction, setActiveGizmoAction] = useState<
113
+ 'resize' | 'radius' | 'move' | null
114
+ >(null);
115
+ const [liveHudBadge, setLiveHudBadge] = useState<string | null>(null);
116
+ const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null);
117
+ const [showCenterGuideX, setShowCenterGuideX] = useState<boolean>(false);
118
+
119
+ // Inline Double-Click Text Editing State
120
+ const [isEditingInlineText, setIsEditingInlineText] = useState<boolean>(false);
121
+ const [inlineTextDraft, setInlineTextDraft] = useState<string>('');
122
+
123
+ const isSelectedOnThisScreen =
124
+ Boolean(selectedElement) && selectedElement?.screenId === screen.id;
125
+ const activeInspected =
126
+ isSelectedOnThisScreen &&
127
+ inspectedElement &&
128
+ inspectedElement.astPath === selectedElement?.astPath
129
+ ? inspectedElement
130
+ : null;
131
+
132
+ const isAbsolutePositioned = activeInspected?.styles?.position === 'absolute';
133
+
134
+ const getSelectedDomNode = useCallback((): HTMLElement | null => {
135
+ const container = viewportRef.current;
136
+ if (!container || !selectedElement || selectedElement.screenId !== screen.id) {
137
+ return null;
138
+ }
139
+ const safeAstPath = escapeAstPathSelector(selectedElement.astPath);
140
+ return container.querySelector<HTMLElement>(`[data-agy-ast-path="${safeAstPath}"]`);
141
+ }, [selectedElement, screen.id]);
142
+
72
143
  const computeRelativeBox = useCallback(
73
144
  (domEl: HTMLElement): BoxOverlay | null => {
74
145
  const container = viewportRef.current;
@@ -90,20 +161,14 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
90
161
 
91
162
  // Keep selected element bounding box updated when screen re-renders or styles change
92
163
  useEffect(() => {
93
- const container = viewportRef.current;
94
- if (
95
- !container ||
96
- !selectedElement ||
97
- selectedElement.screenId !== screen.id
98
- ) {
164
+ setIsEditingInlineText(false);
165
+ if (!isSelectedOnThisScreen) {
99
166
  setSelectedBox(null);
100
167
  return;
101
168
  }
102
169
 
103
170
  const updateSelectionOverlay = () => {
104
- const match = container.querySelector<HTMLElement>(
105
- `[data-agy-ast-path="${selectedElement.astPath}"]`
106
- );
171
+ const match = getSelectedDomNode();
107
172
  if (match) {
108
173
  setSelectedBox(computeRelativeBox(match));
109
174
  } else {
@@ -114,7 +179,44 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
114
179
  updateSelectionOverlay();
115
180
  const timer = window.setTimeout(updateSelectionOverlay, 90);
116
181
  return () => window.clearTimeout(timer);
117
- }, [selectedElement, screen, computeRelativeBox]);
182
+ }, [
183
+ selectedElement,
184
+ inspectedElement,
185
+ screen,
186
+ isSelectedOnThisScreen,
187
+ getSelectedDomNode,
188
+ computeRelativeBox,
189
+ ]);
190
+
191
+ // Focus inline text editor when activated
192
+ useEffect(() => {
193
+ if (isEditingInlineText && inlineInputRef.current) {
194
+ inlineInputRef.current.focus();
195
+ inlineInputRef.current.select();
196
+ }
197
+ }, [isEditingInlineText]);
198
+
199
+ const startInlineTextEdit = useCallback(() => {
200
+ if (!activeInspected || !activeInspected.isTextEditable) return;
201
+ setInlineTextDraft(activeInspected.textContent ?? '');
202
+ setIsEditingInlineText(true);
203
+ }, [activeInspected]);
204
+
205
+ const commitInlineTextEdit = useCallback(() => {
206
+ if (!isEditingInlineText || !selectedElement || !activeInspected) {
207
+ setIsEditingInlineText(false);
208
+ return;
209
+ }
210
+ setIsEditingInlineText(false);
211
+ if (inlineTextDraft !== (activeInspected.textContent ?? '')) {
212
+ void onPatchElement({
213
+ file: selectedElement.file,
214
+ astPath: selectedElement.astPath,
215
+ line: selectedElement.line,
216
+ text: inlineTextDraft,
217
+ });
218
+ }
219
+ }, [isEditingInlineText, selectedElement, activeInspected, inlineTextDraft, onPatchElement]);
118
220
 
119
221
  // Artboard header drag handler to reposition the screen on the infinite canvas
120
222
  const handleHeaderPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -148,9 +250,451 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
148
250
  window.addEventListener('pointerup', handlePointerUp);
149
251
  };
150
252
 
253
+ // 8-Point Direct Resize Handler on Artboard
254
+ const handleResizePointerDown = (
255
+ e: React.PointerEvent<HTMLElement>,
256
+ dir: ResizeDirection
257
+ ) => {
258
+ if (e.button !== 0 || !selectedBox || !selectedElement) return;
259
+ e.stopPropagation();
260
+ e.preventDefault();
261
+
262
+ const targetDomEl = getSelectedDomNode();
263
+ if (!targetDomEl) return;
264
+
265
+ const startClientX = e.clientX;
266
+ const startClientY = e.clientY;
267
+ const initialBox = { ...selectedBox };
268
+ const initialLeft = Number(activeInspected?.styles?.left ?? initialBox.left);
269
+ const initialTop = Number(activeInspected?.styles?.top ?? initialBox.top);
270
+
271
+ // Measure parent inner content width so dragging to parent edges can snap to '100%'
272
+ let parentInnerWidth = position.width;
273
+ if (targetDomEl.parentElement) {
274
+ const parentRect = targetDomEl.parentElement.getBoundingClientRect();
275
+ const parentComputed = window.getComputedStyle(targetDomEl.parentElement);
276
+ const padL = Number.parseFloat(parentComputed.paddingLeft) || 0;
277
+ const padR = Number.parseFloat(parentComputed.paddingRight) || 0;
278
+ parentInnerWidth = parentRect.width / zoom - padL - padR;
279
+ }
280
+
281
+ setActiveGizmoAction('resize');
282
+ let pendingStyles: Record<string, string | number> = {};
283
+
284
+ const handlePointerMove = (moveEvent: PointerEvent) => {
285
+ const dx = (moveEvent.clientX - startClientX) / zoom;
286
+ const dy = (moveEvent.clientY - startClientY) / zoom;
287
+
288
+ let nextW = initialBox.width;
289
+ let nextH = initialBox.height;
290
+ let nextL = initialLeft;
291
+ let nextT = initialTop;
292
+
293
+ const affectsWidth = dir.includes('e') || dir.includes('w');
294
+ const affectsHeight = dir.includes('s') || dir.includes('n');
295
+
296
+ if (dir.includes('e')) {
297
+ nextW = Math.max(20, Math.round(initialBox.width + dx));
298
+ } else if (dir.includes('w')) {
299
+ nextW = Math.max(20, Math.round(initialBox.width - dx));
300
+ if (isAbsolutePositioned) {
301
+ nextL = Math.round(initialLeft + (initialBox.width - nextW));
302
+ }
303
+ }
304
+
305
+ if (dir.includes('s')) {
306
+ nextH = Math.max(18, Math.round(initialBox.height + dy));
307
+ } else if (dir.includes('n')) {
308
+ nextH = Math.max(18, Math.round(initialBox.height - dy));
309
+ if (isAbsolutePositioned) {
310
+ nextT = Math.round(initialTop + (initialBox.height - nextH));
311
+ }
312
+ }
313
+
314
+ pendingStyles = {};
315
+ let widthLabel = `${Math.round(nextW)}`;
316
+
317
+ if (affectsWidth) {
318
+ if (
319
+ !isAbsolutePositioned &&
320
+ !moveEvent.altKey &&
321
+ Math.abs(nextW - parentInnerWidth) <= 7
322
+ ) {
323
+ targetDomEl.style.width = '100%';
324
+ pendingStyles.width = '100%';
325
+ widthLabel = '100%';
326
+ } else {
327
+ targetDomEl.style.width = `${nextW}px`;
328
+ pendingStyles.width = nextW;
329
+ }
330
+ if (isAbsolutePositioned && dir.includes('w')) {
331
+ targetDomEl.style.left = `${nextL}px`;
332
+ pendingStyles.left = nextL;
333
+ }
334
+ }
335
+
336
+ if (affectsHeight) {
337
+ targetDomEl.style.height = `${nextH}px`;
338
+ pendingStyles.height = nextH;
339
+ if (isAbsolutePositioned && dir.includes('n')) {
340
+ targetDomEl.style.top = `${nextT}px`;
341
+ pendingStyles.top = nextT;
342
+ }
343
+ }
344
+
345
+ const updatedBox = computeRelativeBox(targetDomEl);
346
+ if (updatedBox) {
347
+ setSelectedBox(updatedBox);
348
+ }
349
+ setLiveHudBadge(`${widthLabel} × ${Math.round(nextH)}`);
350
+ };
351
+
352
+ const handlePointerUp = () => {
353
+ window.removeEventListener('pointermove', handlePointerMove);
354
+ window.removeEventListener('pointerup', handlePointerUp);
355
+ window.removeEventListener('pointercancel', handlePointerUp);
356
+ setActiveGizmoAction(null);
357
+ setLiveHudBadge(null);
358
+
359
+ if (Object.keys(pendingStyles).length > 0) {
360
+ void onPatchElement({
361
+ file: selectedElement.file,
362
+ astPath: selectedElement.astPath,
363
+ line: selectedElement.line,
364
+ styles: pendingStyles,
365
+ });
366
+ }
367
+ };
368
+
369
+ window.addEventListener('pointermove', handlePointerMove);
370
+ window.addEventListener('pointerup', handlePointerUp);
371
+ window.addEventListener('pointercancel', handlePointerUp);
372
+ };
373
+
374
+ // Inner Corner borderRadius Drag Handler
375
+ const handleRadiusPointerDown = (
376
+ e: React.PointerEvent<HTMLElement>,
377
+ corner: 'nw' | 'ne' | 'sw' | 'se'
378
+ ) => {
379
+ if (e.button !== 0 || !selectedBox || !selectedElement) return;
380
+ e.stopPropagation();
381
+ e.preventDefault();
382
+
383
+ const targetDomEl = getSelectedDomNode();
384
+ if (!targetDomEl) return;
385
+
386
+ const startClientX = e.clientX;
387
+ const startClientY = e.clientY;
388
+ const initialRadius = Number(activeInspected?.styles?.borderRadius ?? 0) || 0;
389
+ const maxRadius = Math.max(0, Math.round(Math.min(selectedBox.width, selectedBox.height) / 2));
390
+
391
+ setActiveGizmoAction('radius');
392
+ let nextRadius = initialRadius;
393
+ let didChange = false;
394
+
395
+ const handlePointerMove = (moveEvent: PointerEvent) => {
396
+ const dx = (moveEvent.clientX - startClientX) / zoom;
397
+ const dy = (moveEvent.clientY - startClientY) / zoom;
398
+ const signedX = corner.includes('w') ? dx : -dx;
399
+ const signedY = corner.includes('n') ? dy : -dy;
400
+ const delta = Math.round((signedX + signedY) / 2);
401
+
402
+ nextRadius = Math.max(0, Math.min(maxRadius, initialRadius + delta));
403
+ didChange = true;
404
+ targetDomEl.style.borderRadius = `${nextRadius}px`;
405
+ setLiveHudBadge(`Radius ${nextRadius}px`);
406
+ };
407
+
408
+ const handlePointerUp = () => {
409
+ window.removeEventListener('pointermove', handlePointerMove);
410
+ window.removeEventListener('pointerup', handlePointerUp);
411
+ window.removeEventListener('pointercancel', handlePointerUp);
412
+ setActiveGizmoAction(null);
413
+ setLiveHudBadge(null);
414
+
415
+ if (didChange && nextRadius !== initialRadius) {
416
+ void onPatchElement({
417
+ file: selectedElement.file,
418
+ astPath: selectedElement.astPath,
419
+ line: selectedElement.line,
420
+ styles: { borderRadius: nextRadius },
421
+ });
422
+ }
423
+ };
424
+
425
+ window.addEventListener('pointermove', handlePointerMove);
426
+ window.addEventListener('pointerup', handlePointerUp);
427
+ window.addEventListener('pointercancel', handlePointerUp);
428
+ };
429
+
430
+ // Hybrid On-Canvas Move & Reorder Handler (Flexbox Reorder + Freeform Float + Shift Spacing Nudge)
431
+ const startMoveElementInteraction = useCallback(
432
+ (startClientX: number, startClientY: number, initialAltKey: boolean) => {
433
+ if (!selectedBox || !selectedElement) return;
434
+ const targetDomEl = getSelectedDomNode();
435
+ const container = viewportRef.current;
436
+ if (!targetDomEl || !container) return;
437
+
438
+ const initialBox = { ...selectedBox };
439
+ const initialLeft = Number(activeInspected?.styles?.left ?? initialBox.left);
440
+ const initialTop = Number(activeInspected?.styles?.top ?? initialBox.top);
441
+ const initialMarginTop = Number(activeInspected?.styles?.marginTop ?? 0) || 0;
442
+ const prevTransform = targetDomEl.style.transform;
443
+ const prevOpacity = targetDomEl.style.opacity;
444
+ const prevZIndex = targetDomEl.style.zIndex;
445
+
446
+ let hasExceededThreshold = false;
447
+ let activeDrop: DropIndicator | null = null;
448
+ let pendingStylePatch: Record<string, string | number> | null = null;
449
+
450
+ const handlePointerMove = (moveEvent: PointerEvent) => {
451
+ const rawDx = moveEvent.clientX - startClientX;
452
+ const rawDy = moveEvent.clientY - startClientY;
453
+
454
+ // Wait for 4px movement threshold before entering drag state (per modern-web-guidance)
455
+ if (!hasExceededThreshold) {
456
+ if (Math.hypot(rawDx, rawDy) < 4) return;
457
+ hasExceededThreshold = true;
458
+ setActiveGizmoAction('move');
459
+ }
460
+
461
+ const dx = rawDx / zoom;
462
+ const dy = rawDy / zoom;
463
+ const useFreeformFloat = isAbsolutePositioned || initialAltKey || moveEvent.altKey;
464
+
465
+ if (useFreeformFloat) {
466
+ setDropIndicator(null);
467
+ activeDrop = null;
468
+
469
+ let nextLeft = Math.round(initialLeft + dx);
470
+ const nextTop = Math.round(initialTop + dy);
471
+
472
+ // Snap to horizontal center of the 393px artboard within 4px
473
+ const elementCenterX = nextLeft + initialBox.width / 2;
474
+ const artboardCenterX = position.width / 2;
475
+ if (Math.abs(elementCenterX - artboardCenterX) <= 4) {
476
+ nextLeft = Math.round(artboardCenterX - initialBox.width / 2);
477
+ setShowCenterGuideX(true);
478
+ } else {
479
+ setShowCenterGuideX(false);
480
+ }
481
+
482
+ targetDomEl.style.transform = prevTransform;
483
+ targetDomEl.style.opacity = '0.95';
484
+ targetDomEl.style.position = 'absolute';
485
+ targetDomEl.style.left = `${nextLeft}px`;
486
+ targetDomEl.style.top = `${nextTop}px`;
487
+ targetDomEl.style.width = `${Math.round(initialBox.width)}px`;
488
+ targetDomEl.style.zIndex = '20';
489
+
490
+ pendingStylePatch = {
491
+ position: 'absolute',
492
+ left: nextLeft,
493
+ top: nextTop,
494
+ width: Math.round(initialBox.width),
495
+ };
496
+
497
+ const updatedBox = computeRelativeBox(targetDomEl);
498
+ if (updatedBox) setSelectedBox(updatedBox);
499
+ setLiveHudBadge(`Float X:${nextLeft} Y:${nextTop}`);
500
+ return;
501
+ }
502
+
503
+ setShowCenterGuideX(false);
504
+
505
+ // Shift + Drag in Flexbox mode = Nudge marginTop spacing
506
+ if (moveEvent.shiftKey) {
507
+ setDropIndicator(null);
508
+ activeDrop = null;
509
+ const nextMarginTop = Math.round(initialMarginTop + dy);
510
+ targetDomEl.style.transform = prevTransform;
511
+ targetDomEl.style.marginTop = `${nextMarginTop}px`;
512
+ pendingStylePatch = { marginTop: nextMarginTop };
513
+ const updatedBox = computeRelativeBox(targetDomEl);
514
+ if (updatedBox) setSelectedBox(updatedBox);
515
+ setLiveHudBadge(`marginTop: ${nextMarginTop}px`);
516
+ return;
517
+ }
518
+
519
+ // Default Flexbox Drag = Reorder among sibling JSX elements inside parent container
520
+ pendingStylePatch = null;
521
+ targetDomEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`;
522
+ targetDomEl.style.opacity = '0.84';
523
+ targetDomEl.style.zIndex = '30';
524
+
525
+ const parentEl = targetDomEl.parentElement;
526
+ if (!parentEl) return;
527
+
528
+ const parentStyle = window.getComputedStyle(parentEl);
529
+ const isRow = parentStyle.flexDirection.startsWith('row');
530
+ const containerRect = container.getBoundingClientRect();
531
+ const pointerX = (moveEvent.clientX - containerRect.left) / zoom;
532
+ const pointerY = (moveEvent.clientY - containerRect.top) / zoom;
533
+
534
+ const siblings = Array.from(parentEl.children).filter(
535
+ (child): child is HTMLElement =>
536
+ child instanceof HTMLElement &&
537
+ child !== targetDomEl &&
538
+ Boolean(child.dataset?.agyAstPath)
539
+ );
540
+
541
+ if (siblings.length === 0) {
542
+ // No siblings in parent container: fall back to marginTop nudge
543
+ const nextMarginTop = Math.round(initialMarginTop + dy);
544
+ pendingStylePatch = { marginTop: nextMarginTop };
545
+ setLiveHudBadge(`Offset Y: ${nextMarginTop}px`);
546
+ return;
547
+ }
548
+
549
+ let closestSibling: HTMLElement | null = null;
550
+ let closestDist = Number.POSITIVE_INFINITY;
551
+ let closestBox: BoxOverlay | null = null;
552
+
553
+ for (const sib of siblings) {
554
+ const box = computeRelativeBox(sib);
555
+ if (!box) continue;
556
+ const centerX = box.left + box.width / 2;
557
+ const centerY = box.top + box.height / 2;
558
+ const dist = isRow
559
+ ? Math.abs(pointerX - centerX)
560
+ : Math.abs(pointerY - centerY);
561
+ if (dist < closestDist) {
562
+ closestDist = dist;
563
+ closestSibling = sib;
564
+ closestBox = box;
565
+ }
566
+ }
567
+
568
+ if (closestSibling && closestBox) {
569
+ const placement: 'before' | 'after' = isRow
570
+ ? pointerX < closestBox.left + closestBox.width / 2
571
+ ? 'before'
572
+ : 'after'
573
+ : pointerY < closestBox.top + closestBox.height / 2
574
+ ? 'before'
575
+ : 'after';
576
+
577
+ const indicator: DropIndicator = isRow
578
+ ? {
579
+ targetAstPath: closestBox.astPath,
580
+ targetTagName: closestBox.tagName,
581
+ placement,
582
+ isRow: true,
583
+ lineTop: closestBox.top,
584
+ lineLeft:
585
+ placement === 'before'
586
+ ? closestBox.left - 3
587
+ : closestBox.left + closestBox.width + 3,
588
+ lineWidth: 2,
589
+ lineHeight: closestBox.height,
590
+ }
591
+ : {
592
+ targetAstPath: closestBox.astPath,
593
+ targetTagName: closestBox.tagName,
594
+ placement,
595
+ isRow: false,
596
+ lineTop:
597
+ placement === 'before'
598
+ ? closestBox.top - 3
599
+ : closestBox.top + closestBox.height + 3,
600
+ lineLeft: closestBox.left,
601
+ lineWidth: closestBox.width,
602
+ lineHeight: 2,
603
+ };
604
+
605
+ activeDrop = indicator;
606
+ setDropIndicator(indicator);
607
+ setLiveHudBadge(`Move ${placement} <${closestBox.tagName}>`);
608
+ }
609
+ };
610
+
611
+ const handlePointerUp = () => {
612
+ window.removeEventListener('pointermove', handlePointerMove);
613
+ window.removeEventListener('pointerup', handlePointerUp);
614
+ window.removeEventListener('pointercancel', handlePointerUp);
615
+
616
+ targetDomEl.style.transform = prevTransform;
617
+ targetDomEl.style.opacity = prevOpacity;
618
+ targetDomEl.style.zIndex = prevZIndex;
619
+
620
+ setActiveGizmoAction(null);
621
+ setLiveHudBadge(null);
622
+ setDropIndicator(null);
623
+ setShowCenterGuideX(false);
624
+
625
+ if (!hasExceededThreshold) return;
626
+
627
+ if (activeDrop) {
628
+ void onReorderElement({
629
+ file: selectedElement.file,
630
+ astPath: selectedElement.astPath,
631
+ targetAstPath: activeDrop.targetAstPath,
632
+ placement: activeDrop.placement,
633
+ });
634
+ } else if (pendingStylePatch) {
635
+ void onPatchElement({
636
+ file: selectedElement.file,
637
+ astPath: selectedElement.astPath,
638
+ line: selectedElement.line,
639
+ styles: pendingStylePatch,
640
+ });
641
+ }
642
+ };
643
+
644
+ window.addEventListener('pointermove', handlePointerMove);
645
+ window.addEventListener('pointerup', handlePointerUp);
646
+ window.addEventListener('pointercancel', handlePointerUp);
647
+ },
648
+ [
649
+ selectedBox,
650
+ selectedElement,
651
+ activeInspected,
652
+ isAbsolutePositioned,
653
+ zoom,
654
+ position.width,
655
+ getSelectedDomNode,
656
+ computeRelativeBox,
657
+ onPatchElement,
658
+ onReorderElement,
659
+ ]
660
+ );
661
+
662
+ // Toggle between Flexbox flow and Freeform Float (position: 'absolute')
663
+ const handleToggleFloatMode = (e: React.MouseEvent<HTMLButtonElement>) => {
664
+ e.stopPropagation();
665
+ if (!selectedElement || !selectedBox) return;
666
+
667
+ if (isAbsolutePositioned) {
668
+ void onPatchElement({
669
+ file: selectedElement.file,
670
+ astPath: selectedElement.astPath,
671
+ line: selectedElement.line,
672
+ styles: {
673
+ position: null,
674
+ left: null,
675
+ top: null,
676
+ zIndex: null,
677
+ },
678
+ });
679
+ } else {
680
+ void onPatchElement({
681
+ file: selectedElement.file,
682
+ astPath: selectedElement.astPath,
683
+ line: selectedElement.line,
684
+ styles: {
685
+ position: 'absolute',
686
+ left: Math.round(selectedBox.left),
687
+ top: Math.round(selectedBox.top),
688
+ width: Math.round(selectedBox.width),
689
+ zIndex: 10,
690
+ },
691
+ });
692
+ }
693
+ };
694
+
151
695
  // Inspect Mode pointer handlers
152
696
  const handlePointerMoveCapture = (e: React.PointerEvent<HTMLDivElement>) => {
153
- if (mode !== 'inspect') return;
697
+ if (mode !== 'inspect' || activeGizmoAction) return;
154
698
  const targetEl = findNearestAgyElement(e.target, viewportRef.current);
155
699
  if (!targetEl) {
156
700
  setHoverBox(null);
@@ -163,8 +707,23 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
163
707
  setHoverBox(null);
164
708
  };
165
709
 
710
+ const handleViewportPointerDownCapture = (e: React.PointerEvent<HTMLDivElement>) => {
711
+ if (mode !== 'inspect' || e.button !== 0 || isEditingInlineText) return;
712
+ const targetEl = findNearestAgyElement(e.target, viewportRef.current);
713
+ if (!targetEl) return;
714
+
715
+ // If user presses down on the already-selected element, allow dragging it after 4px threshold
716
+ if (
717
+ isSelectedOnThisScreen &&
718
+ selectedElement &&
719
+ targetEl.dataset.agyAstPath === selectedElement.astPath
720
+ ) {
721
+ startMoveElementInteraction(e.clientX, e.clientY, e.altKey);
722
+ }
723
+ };
724
+
166
725
  const handleClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
167
- if (mode !== 'inspect') return;
726
+ if (mode !== 'inspect' || isEditingInlineText) return;
168
727
  e.stopPropagation();
169
728
  e.preventDefault();
170
729
 
@@ -189,12 +748,26 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
189
748
  });
190
749
  };
191
750
 
751
+ const handleDoubleClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
752
+ if (mode !== 'inspect') return;
753
+ const targetEl = findNearestAgyElement(e.target, viewportRef.current);
754
+ if (!targetEl) return;
755
+
756
+ e.stopPropagation();
757
+ e.preventDefault();
758
+ if (activeInspected?.isTextEditable) {
759
+ startInlineTextEdit();
760
+ }
761
+ };
762
+
192
763
  const borderColor = isHighlightedScreen
193
764
  ? '#10B981'
194
765
  : isActiveScreen
195
766
  ? '#38BDF8'
196
767
  : 'rgba(255, 255, 255, 0.09)';
197
768
 
769
+ const toolbarAbove = !selectedBox || selectedBox.top >= 30;
770
+
198
771
  return (
199
772
  <div
200
773
  style={{
@@ -203,6 +776,7 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
203
776
  top: position.y,
204
777
  width: position.width,
205
778
  userSelect: 'none',
779
+ WebkitUserSelect: 'none',
206
780
  zIndex: isActiveScreen || isHighlightedScreen ? 20 : 10,
207
781
  }}
208
782
  onPointerDown={(e) => {
@@ -285,12 +859,14 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
285
859
  </div>
286
860
  </div>
287
861
 
288
- {/* Pure Rectangular Screen Artboard (No Device Bezel/Frame, Soft Diffusion Shadows) */}
862
+ {/* Pure Rectangular Screen Artboard */}
289
863
  <div
290
864
  ref={viewportRef}
865
+ onPointerDownCapture={handleViewportPointerDownCapture}
291
866
  onPointerMoveCapture={handlePointerMoveCapture}
292
867
  onPointerLeave={handlePointerLeave}
293
868
  onClickCapture={handleClickCapture}
869
+ onDoubleClickCapture={handleDoubleClickCapture}
294
870
  style={{
295
871
  position: 'relative',
296
872
  width: position.width,
@@ -304,7 +880,8 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
304
880
  : isActiveScreen
305
881
  ? '0 0 0 2px rgba(56, 189, 248, 0.22), 0 28px 56px -14px rgba(0, 0, 0, 0.78)'
306
882
  : '0 22px 44px -15px rgba(0, 0, 0, 0.68)',
307
- transition: 'border-color var(--duration-micro) var(--ease-out), box-shadow var(--duration-micro) var(--ease-out)',
883
+ transition:
884
+ 'border-color var(--duration-micro) var(--ease-out), box-shadow var(--duration-micro) var(--ease-out)',
308
885
  display: 'flex',
309
886
  flexDirection: 'column',
310
887
  cursor: mode === 'inspect' ? 'crosshair' : 'default',
@@ -324,7 +901,16 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
324
901
  fontFamily: 'var(--font-mono)',
325
902
  }}
326
903
  >
327
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#F87171', fontWeight: 700, fontSize: 13 }}>
904
+ <div
905
+ style={{
906
+ display: 'flex',
907
+ alignItems: 'center',
908
+ gap: 8,
909
+ color: '#F87171',
910
+ fontWeight: 700,
911
+ fontSize: 13,
912
+ }}
913
+ >
328
914
  <AlertTriangle size={16} />
329
915
  <span>AST Syntax Error in {screen.filePath}</span>
330
916
  </div>
@@ -338,19 +924,93 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
338
924
  routeName={screen.routeName}
339
925
  onNavigate={onNavigateFlow}
340
926
  >
341
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
927
+ <div
928
+ style={{
929
+ flex: 1,
930
+ display: 'flex',
931
+ flexDirection: 'column',
932
+ height: '100%',
933
+ width: '100%',
934
+ }}
935
+ >
342
936
  <ScreenComponent />
343
937
  </div>
344
938
  </MockNavigationProvider>
345
939
  </ScreenErrorBoundary>
346
940
  ) : (
347
- <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748B', fontSize: 13 }}>
941
+ <div
942
+ style={{
943
+ flex: 1,
944
+ display: 'flex',
945
+ alignItems: 'center',
946
+ justifyContent: 'center',
947
+ color: '#64748B',
948
+ fontSize: 13,
949
+ }}
950
+ >
348
951
  Mounting {screen.name}...
349
952
  </div>
350
953
  )}
351
954
 
955
+ {/* Smart Horizontal Center Snap Guide Line during Freeform Float Move */}
956
+ {showCenterGuideX && (
957
+ <div
958
+ style={{
959
+ position: 'absolute',
960
+ top: 0,
961
+ bottom: 0,
962
+ left: Math.round(position.width / 2),
963
+ width: 1,
964
+ borderLeft: '1px dashed #F43F5E',
965
+ pointerEvents: 'none',
966
+ zIndex: 55,
967
+ }}
968
+ />
969
+ )}
970
+
971
+ {/* Smart Flexbox Drop Insertion Line during Sibling Reorder Drag */}
972
+ {dropIndicator && (
973
+ <div
974
+ style={{
975
+ position: 'absolute',
976
+ top: dropIndicator.lineTop,
977
+ left: dropIndicator.lineLeft,
978
+ width: dropIndicator.lineWidth,
979
+ height: dropIndicator.lineHeight,
980
+ backgroundColor: '#38BDF8',
981
+ boxShadow: '0 0 8px rgba(56, 189, 248, 0.9)',
982
+ pointerEvents: 'none',
983
+ zIndex: 55,
984
+ }}
985
+ >
986
+ <span
987
+ style={{
988
+ position: 'absolute',
989
+ top: dropIndicator.isRow ? -3 : -2,
990
+ left: dropIndicator.isRow ? -2 : -3,
991
+ width: 6,
992
+ height: 6,
993
+ borderRadius: '50%',
994
+ backgroundColor: '#38BDF8',
995
+ }}
996
+ />
997
+ <span
998
+ style={{
999
+ position: 'absolute',
1000
+ bottom: dropIndicator.isRow ? -3 : -2,
1001
+ right: dropIndicator.isRow ? -2 : -3,
1002
+ width: 6,
1003
+ height: 6,
1004
+ borderRadius: '50%',
1005
+ backgroundColor: '#38BDF8',
1006
+ }}
1007
+ />
1008
+ </div>
1009
+ )}
1010
+
352
1011
  {/* Figma-style Hover Bounding Box in Inspect Mode */}
353
1012
  {mode === 'inspect' &&
1013
+ !activeGizmoAction &&
354
1014
  hoverBox &&
355
1015
  (!selectedBox || hoverBox.astPath !== selectedBox.astPath) && (
356
1016
  <div
@@ -388,8 +1048,8 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
388
1048
  </div>
389
1049
  )}
390
1050
 
391
- {/* Figma-style Active Selection Bounding Box */}
392
- {selectedBox && selectedElement?.screenId === screen.id && (
1051
+ {/* Interactive Direct-Manipulation Selection Gizmo */}
1052
+ {mode === 'inspect' && selectedBox && isSelectedOnThisScreen && selectedElement && (
393
1053
  <div
394
1054
  className="zelune-box-overlay"
395
1055
  style={{
@@ -398,51 +1058,356 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
398
1058
  left: selectedBox.left,
399
1059
  width: selectedBox.width,
400
1060
  height: selectedBox.height,
401
- border: '1.5px solid #6366F1',
402
- backgroundColor: 'rgba(99, 102, 241, 0.08)',
1061
+ border: `1.5px solid ${isAbsolutePositioned ? '#F59E0B' : '#6366F1'}`,
1062
+ backgroundColor: isAbsolutePositioned
1063
+ ? 'rgba(245, 158, 11, 0.07)'
1064
+ : 'rgba(99, 102, 241, 0.07)',
403
1065
  pointerEvents: 'none',
404
1066
  zIndex: 45,
405
1067
  }}
406
1068
  >
407
- {/* Corner resize handles aesthetic */}
408
- {[
409
- { top: -3.5, left: -3.5 },
410
- { top: -3.5, right: -3.5 },
411
- { bottom: -3.5, left: -3.5 },
412
- { bottom: -3.5, right: -3.5 },
413
- ].map((pos, idx) => (
1069
+ {/* 8-Point Interactive Resize Handles (4 Corners + 4 Edges) */}
1070
+ {(
1071
+ [
1072
+ {
1073
+ dir: 'nw' as ResizeDirection,
1074
+ style: { top: -5, left: -5, cursor: 'nwse-resize' },
1075
+ },
1076
+ {
1077
+ dir: 'n' as ResizeDirection,
1078
+ style: {
1079
+ top: -4,
1080
+ left: '50%',
1081
+ transform: 'translateX(-50%)',
1082
+ cursor: 'ns-resize',
1083
+ },
1084
+ },
1085
+ {
1086
+ dir: 'ne' as ResizeDirection,
1087
+ style: { top: -5, right: -5, cursor: 'nesw-resize' },
1088
+ },
1089
+ {
1090
+ dir: 'e' as ResizeDirection,
1091
+ style: {
1092
+ top: '50%',
1093
+ right: -4,
1094
+ transform: 'translateY(-50%)',
1095
+ cursor: 'ew-resize',
1096
+ },
1097
+ },
1098
+ {
1099
+ dir: 'se' as ResizeDirection,
1100
+ style: { bottom: -5, right: -5, cursor: 'nwse-resize' },
1101
+ },
1102
+ {
1103
+ dir: 's' as ResizeDirection,
1104
+ style: {
1105
+ bottom: -4,
1106
+ left: '50%',
1107
+ transform: 'translateX(-50%)',
1108
+ cursor: 'ns-resize',
1109
+ },
1110
+ },
1111
+ {
1112
+ dir: 'sw' as ResizeDirection,
1113
+ style: { bottom: -5, left: -5, cursor: 'nesw-resize' },
1114
+ },
1115
+ {
1116
+ dir: 'w' as ResizeDirection,
1117
+ style: {
1118
+ top: '50%',
1119
+ left: -4,
1120
+ transform: 'translateY(-50%)',
1121
+ cursor: 'ew-resize',
1122
+ },
1123
+ },
1124
+ ] as const
1125
+ ).map(({ dir, style }) => (
414
1126
  <span
415
- key={idx}
1127
+ key={dir}
1128
+ title={`Drag to resize (${dir.toUpperCase()})`}
1129
+ onPointerDown={(e) => handleResizePointerDown(e, dir)}
1130
+ onClick={(e) => e.stopPropagation()}
416
1131
  style={{
417
1132
  position: 'absolute',
418
- ...pos,
419
- width: 6,
420
- height: 6,
1133
+ ...style,
1134
+ width: dir.length === 2 ? 8 : 6,
1135
+ height: dir.length === 2 ? 8 : 6,
421
1136
  backgroundColor: '#FFFFFF',
422
- border: '1.5px solid #4F46E5',
423
- borderRadius: 1,
1137
+ border: `1.5px solid ${isAbsolutePositioned ? '#D97706' : '#4F46E5'}`,
1138
+ borderRadius: dir.length === 2 ? 2 : 1,
1139
+ pointerEvents: 'auto',
1140
+ touchAction: 'none',
1141
+ WebkitUserSelect: 'none',
1142
+ userSelect: 'none',
1143
+ boxShadow: '0 1px 3px rgba(0,0,0,0.45)',
424
1144
  }}
425
1145
  />
426
1146
  ))}
427
- <span
428
- className="tabular-nums"
1147
+
1148
+ {/* 4 Inner Corner borderRadius Handles (Figma-style circular dots) */}
1149
+ {selectedBox.width >= 44 &&
1150
+ selectedBox.height >= 32 &&
1151
+ !isEditingInlineText &&
1152
+ (
1153
+ [
1154
+ { corner: 'nw' as const, style: { top: 6, left: 6 } },
1155
+ { corner: 'ne' as const, style: { top: 6, right: 6 } },
1156
+ { corner: 'sw' as const, style: { bottom: 6, left: 6 } },
1157
+ { corner: 'se' as const, style: { bottom: 6, right: 6 } },
1158
+ ] as const
1159
+ ).map(({ corner, style }) => (
1160
+ <span
1161
+ key={corner}
1162
+ title="Drag inward/outward to adjust borderRadius"
1163
+ onPointerDown={(e) => handleRadiusPointerDown(e, corner)}
1164
+ onClick={(e) => e.stopPropagation()}
1165
+ style={{
1166
+ position: 'absolute',
1167
+ ...style,
1168
+ width: 6,
1169
+ height: 6,
1170
+ borderRadius: '50%',
1171
+ backgroundColor: 'rgba(56, 189, 248, 0.9)',
1172
+ border: '1px solid #08090C',
1173
+ cursor: 'crosshair',
1174
+ pointerEvents: 'auto',
1175
+ touchAction: 'none',
1176
+ WebkitUserSelect: 'none',
1177
+ userSelect: 'none',
1178
+ opacity: 0.75,
1179
+ }}
1180
+ />
1181
+ ))}
1182
+
1183
+ {/* On-Canvas Floating Action & Move Toolbar Pill */}
1184
+ <div
1185
+ onClick={(e) => e.stopPropagation()}
429
1186
  style={{
430
1187
  position: 'absolute',
431
- top: -21,
432
- left: -1,
433
- backgroundColor: '#4F46E5',
1188
+ ...(toolbarAbove ? { top: -27 } : { bottom: -27 }),
1189
+ left: -1.5,
1190
+ display: 'inline-flex',
1191
+ alignItems: 'center',
1192
+ gap: 2,
1193
+ backgroundColor: isAbsolutePositioned ? '#B45309' : '#4F46E5',
434
1194
  color: '#FFFFFF',
435
1195
  fontSize: 10,
436
1196
  fontWeight: 700,
437
1197
  fontFamily: 'var(--font-mono)',
438
- padding: '2px 6px',
1198
+ padding: '2px 4px 2px 6px',
1199
+ borderRadius: 4,
1200
+ whiteSpace: 'nowrap',
1201
+ boxShadow: '0 4px 10px rgba(0, 0, 0, 0.5)',
1202
+ pointerEvents: 'auto',
1203
+ touchAction: 'none',
1204
+ WebkitUserSelect: 'none',
1205
+ userSelect: 'none',
1206
+ zIndex: 50,
1207
+ }}
1208
+ >
1209
+ {/* Drag Grip Handle on the Tag Pill */}
1210
+ <span
1211
+ className="tabular-nums"
1212
+ title="Drag to move or reorder element (Hold Option/Alt for freeform X/Y, Shift for margin offset)"
1213
+ onPointerDown={(e) => {
1214
+ if (e.button !== 0) return;
1215
+ e.stopPropagation();
1216
+ e.preventDefault();
1217
+ startMoveElementInteraction(e.clientX, e.clientY, e.altKey);
1218
+ }}
1219
+ style={{
1220
+ display: 'inline-flex',
1221
+ alignItems: 'center',
1222
+ gap: 4,
1223
+ cursor: activeGizmoAction === 'move' ? 'grabbing' : 'grab',
1224
+ paddingRight: 4,
1225
+ }}
1226
+ >
1227
+ <Move size={10} />
1228
+ <span>
1229
+ &lt;{selectedBox.tagName}&gt; · L{selectedElement.line}
1230
+ </span>
1231
+ </span>
1232
+
1233
+ {/* Quick Reorder Up inside Flexbox Parent */}
1234
+ <button
1235
+ type="button"
1236
+ title="Move element earlier/up in Flexbox stack"
1237
+ onClick={(e) => {
1238
+ e.stopPropagation();
1239
+ void onReorderElement({
1240
+ file: selectedElement.file,
1241
+ astPath: selectedElement.astPath,
1242
+ direction: 'up',
1243
+ });
1244
+ }}
1245
+ style={{
1246
+ display: 'inline-flex',
1247
+ alignItems: 'center',
1248
+ justifyContent: 'center',
1249
+ width: 17,
1250
+ height: 17,
1251
+ border: 'none',
1252
+ borderRadius: 3,
1253
+ backgroundColor: 'rgba(255, 255, 255, 0.14)',
1254
+ color: '#FFFFFF',
1255
+ cursor: 'pointer',
1256
+ padding: 0,
1257
+ }}
1258
+ >
1259
+ <ArrowUp size={10} />
1260
+ </button>
1261
+
1262
+ {/* Quick Reorder Down inside Flexbox Parent */}
1263
+ <button
1264
+ type="button"
1265
+ title="Move element later/down in Flexbox stack"
1266
+ onClick={(e) => {
1267
+ e.stopPropagation();
1268
+ void onReorderElement({
1269
+ file: selectedElement.file,
1270
+ astPath: selectedElement.astPath,
1271
+ direction: 'down',
1272
+ });
1273
+ }}
1274
+ style={{
1275
+ display: 'inline-flex',
1276
+ alignItems: 'center',
1277
+ justifyContent: 'center',
1278
+ width: 17,
1279
+ height: 17,
1280
+ border: 'none',
1281
+ borderRadius: 3,
1282
+ backgroundColor: 'rgba(255, 255, 255, 0.14)',
1283
+ color: '#FFFFFF',
1284
+ cursor: 'pointer',
1285
+ padding: 0,
1286
+ }}
1287
+ >
1288
+ <ArrowDown size={10} />
1289
+ </button>
1290
+
1291
+ {/* Toggle Freeform Float (position: 'absolute') vs Flexbox Flow */}
1292
+ <button
1293
+ type="button"
1294
+ title={
1295
+ isAbsolutePositioned
1296
+ ? 'Unpin element back to Flexbox flow'
1297
+ : 'Pin/Float element for freeform (X, Y) dragging'
1298
+ }
1299
+ onClick={handleToggleFloatMode}
1300
+ style={{
1301
+ display: 'inline-flex',
1302
+ alignItems: 'center',
1303
+ justifyContent: 'center',
1304
+ width: 17,
1305
+ height: 17,
1306
+ border: 'none',
1307
+ borderRadius: 3,
1308
+ backgroundColor: isAbsolutePositioned
1309
+ ? 'rgba(15, 23, 42, 0.45)'
1310
+ : 'rgba(255, 255, 255, 0.14)',
1311
+ color: '#FFFFFF',
1312
+ cursor: 'pointer',
1313
+ padding: 0,
1314
+ }}
1315
+ >
1316
+ {isAbsolutePositioned ? <PinOff size={10} /> : <Pin size={10} />}
1317
+ </button>
1318
+
1319
+ {/* Inline Text Edit Trigger Button (for editable <Text> nodes) */}
1320
+ {activeInspected?.isTextEditable && (
1321
+ <button
1322
+ type="button"
1323
+ title="Edit text directly on artboard (or double-click element)"
1324
+ onClick={(e) => {
1325
+ e.stopPropagation();
1326
+ startInlineTextEdit();
1327
+ }}
1328
+ style={{
1329
+ display: 'inline-flex',
1330
+ alignItems: 'center',
1331
+ justifyContent: 'center',
1332
+ width: 17,
1333
+ height: 17,
1334
+ border: 'none',
1335
+ borderRadius: 3,
1336
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
1337
+ color: '#FFFFFF',
1338
+ cursor: 'pointer',
1339
+ padding: 0,
1340
+ }}
1341
+ >
1342
+ <Type size={10} />
1343
+ </button>
1344
+ )}
1345
+ </div>
1346
+
1347
+ {/* Live HUD Readout Pill (Dimensions / Radius / Move Feedback) */}
1348
+ <span
1349
+ className="tabular-nums"
1350
+ style={{
1351
+ position: 'absolute',
1352
+ ...(toolbarAbove ? { bottom: -20 } : { top: -20 }),
1353
+ right: -1,
1354
+ backgroundColor: '#0D0F14',
1355
+ border: '1px solid rgba(255, 255, 255, 0.15)',
1356
+ color: '#38BDF8',
1357
+ fontSize: 9.5,
1358
+ fontWeight: 600,
1359
+ fontFamily: 'var(--font-mono)',
1360
+ padding: '1px 5px',
439
1361
  borderRadius: 3,
440
1362
  whiteSpace: 'nowrap',
441
- boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
1363
+ pointerEvents: 'none',
1364
+ boxShadow: '0 2px 6px rgba(0, 0, 0, 0.5)',
442
1365
  }}
443
1366
  >
444
- &lt;{selectedBox.tagName}&gt; · L{selectedElement.line}
1367
+ {liveHudBadge ??
1368
+ `${Math.round(selectedBox.width)} × ${Math.round(selectedBox.height)}`}
445
1369
  </span>
1370
+
1371
+ {/* Inline Double-Click On-Canvas <Text> Editor */}
1372
+ {isEditingInlineText && (
1373
+ <textarea
1374
+ ref={inlineInputRef}
1375
+ value={inlineTextDraft}
1376
+ onChange={(e) => setInlineTextDraft(e.target.value)}
1377
+ onBlur={commitInlineTextEdit}
1378
+ onClick={(e) => e.stopPropagation()}
1379
+ onPointerDown={(e) => e.stopPropagation()}
1380
+ onKeyDown={(e) => {
1381
+ e.stopPropagation();
1382
+ if (e.key === 'Enter' && !e.shiftKey) {
1383
+ e.preventDefault();
1384
+ commitInlineTextEdit();
1385
+ } else if (e.key === 'Escape') {
1386
+ e.preventDefault();
1387
+ setIsEditingInlineText(false);
1388
+ }
1389
+ }}
1390
+ style={{
1391
+ position: 'absolute',
1392
+ inset: -2,
1393
+ width: Math.max(selectedBox.width + 8, 90),
1394
+ height: Math.max(selectedBox.height + 6, 28),
1395
+ backgroundColor: 'rgba(13, 15, 20, 0.96)',
1396
+ color: String(activeInspected?.styles?.color ?? '#F8FAFC'),
1397
+ fontSize: Number(activeInspected?.styles?.fontSize ?? 14),
1398
+ fontWeight: String(activeInspected?.styles?.fontWeight ?? '500'),
1399
+ fontFamily: 'var(--font-sans)',
1400
+ border: '1.5px solid #38BDF8',
1401
+ borderRadius: 4,
1402
+ padding: '2px 5px',
1403
+ outline: 'none',
1404
+ resize: 'none',
1405
+ pointerEvents: 'auto',
1406
+ zIndex: 60,
1407
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.65)',
1408
+ }}
1409
+ />
1410
+ )}
446
1411
  </div>
447
1412
  )}
448
1413
  </div>