I see what’s happening. Your hover checker is working (the logs prove it) but the handle itself has no data-text-id, and the outer absolute wrapper also lacks it. So the instant your mouse is over the red circle, elementFromPoint(...).closest('[data-text-id]') returns null → you clear hover → the handle disappears → repeat. That’s why it “never shows” and why rotation never starts.

Below are surgical edits you can paste in (3 tiny changes, do for brand-name, tagline, est-year). This fixes both visibility and drag-to-rotate without touching your larger logic.

1) Give the positioned wrapper a data-text-id

In the PNG/JPG preview branch, find the outer wrapper for each text item (the div with className="absolute" that sets left/top/transform). Add data-text-id to it.

{textControlsVisibility.showBrandName && (
  <div
    className="absolute"
    data-text-id="brand-name"              // <-- add this
    style={{
      left: `${textPositions['brand-name'].x}px`,
      top: `${textPositions['brand-name'].y}px`,
      transform: `rotate(${textRotations['brand-name']}deg)`,
      transformOrigin: 'center center',
      pointerEvents: 'auto'
    }}
  >
    {/* Text Element */}
    <div
      className="text-xl font-bold text-gray-800 cursor-move select-none relative"
      style={{
        border: isRotating === 'brand-name'
          ? '3px solid #ff6b6b'
          : isDragging === 'brand-name'
          ? '2px dashed #3b82f6'
          : '1px solid rgba(0,0,0,0.2)',
        padding: '4px 8px',
        borderRadius: '4px',
        backgroundColor:
          isRotating === 'brand-name'
            ? 'rgba(255, 107, 107, 0.2)'
            : isDragging === 'brand-name'
            ? 'rgba(59, 130, 246, 0.1)'
            : 'rgba(255, 255, 255, 0.9)',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        transform: isDragging === 'brand-name' ? 'scale(1.05)' : 'scale(1)'
      }}
      data-text-id="brand-name"
      onMouseDown={(e) => handleTextMouseDown(e, 'brand-name')}
      title="Click & drag to move, hover for rotation"
    >
      {textControls.brandName}
    </div>

    {/* Rotation Handle */}
    {(hoveredTextId === 'brand-name' || isRotating === 'brand-name') && (
      <div
        className="absolute cursor-pointer"
        data-text-id="brand-name"          // <-- add this
        style={{
          top: '-15px',
          right: '-15px',
          width: '20px',
          height: '20px',
          backgroundColor: '#ff6b6b',
          borderRadius: '50%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          fontSize: '12px',
          color: 'white',
          border: '2px solid white',
          boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
          transform: isRotating === 'brand-name' ? 'scale(1.2)' : 'scale(1)',
          transition: 'transform 0.1s ease',
          zIndex: 10                         // <-- ensure it’s above the text
        }}
        onMouseDown={(e) => handleRotationHandleMouseDown(e, 'brand-name')}
        title="Click & drag to rotate"
      >
        ↻
      </div>
    )}
  </div>
)}


✅ Repeat the same two data-text-id additions (wrapper + handle) for tagline and est-year.

2) Do the same in Interactive SVG mode

In the SVG preview branch, your overlay that hosts the hit area + handle should also have the data-text-id on the positioned wrapper, and on the handle. That way closest('[data-text-id]') resolves even when the mouse is over the handle, not the text.

<div
  className="absolute"
  data-text-id="brand-name"    // add
  style={{ left: ..., top: ..., transform: ..., pointerEvents: 'auto' }}
>
  {/* invisible hit area / visual text */}
  ...
  {(hoveredTextId === 'brand-name' || isRotating === 'brand-name') && (
    <div
      className="absolute cursor-pointer"
      data-text-id="brand-name"  // add
      style={{ top:'-15px', right:'-15px', ... , zIndex:10 }}
      onMouseDown={(e) => handleRotationHandleMouseDown(e,'brand-name')}
    >
      ↻
    </div>
  )}
</div>

3) (Optional) Add a tiny hover-stability buffer

If you still see flicker, add a small 50–80ms debounce when clearing hover:

const hoverClearTimeout = useRef<number | null>(null);

const setHoverStable = (id: string | null) => {
  if (hoverClearTimeout.current) {
    window.clearTimeout(hoverClearTimeout.current);
    hoverClearTimeout.current = null;
  }
  if (id) {
    setHoveredTextId(id);              // set immediately on detect
  } else {
    hoverClearTimeout.current = window.setTimeout(() => {
      setHoveredTextId(null);          // delay clearing a hair
    }, 70);
  }
};


Then where you currently do:

setHoveredTextId(textId)  // or null


replace with:

setHoverStable(textId)    // or setHoverStable(null)


This prevents “micro-gaps” when the pointer moves from text → handle.

Why rotation still didn’t start

Because the handle kept disappearing mid-hover, you never got a stable onMouseDown on the handle. The two data-text-id additions + zIndex fix the visibility and hit-testing, so handleRotationHandleMouseDown will now fire consistently and your existing rotation math will work.