import {
    resolveLocalizedText,
    resolveTextAlign,
    type Canvas,
    type Color,
    type DevicePresetDto,
    type FrameLayer,
    type Layer,
    type ShapeLayer,
    type CalloutLayer,
} from '@/types/layout';
import { useActiveLocaleDirection } from './locale';
import Konva from 'konva';
import type { Filter as KonvaFilter } from 'konva/lib/Node';
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
    Arrow,
    Ellipse,
    Group,
    Image as KonvaImage,
    Line,
    Rect,
    Shape as KonvaShape,
    Text,
} from 'react-konva';
import { resolveAnchors } from './anchors';
import { BLUR_FILTERS, nativeBlurEllipseScene, solidFillValue } from './blurFilter';
import { makeRichCharRenderFunc, resolveRichRuns } from './richText';
import { colorToCanvasFill, colorToKonvaFill, colorToKonvaStroke, strokeColorAsColor } from './colors';
import { bezelGeometry } from './frame';
import { mediaUrl } from './media';
import { buildTrapezoidPath, drawTilt, realFrameTiltAllowed } from './perspective';
import { fontStack, isComplexScript, localeAwareFontStack, safeLetterSpacing, safeLineHeight } from './fonts';
import { resolveShadows, toKonvaShadowProps, withShadows } from './shadows';
import { buildShapePath } from './shapePath';
import {
    applyTextTransform,
    composeFontStyle,
    fitTextSize,
    measureTextNaturalSize,
} from './textFit';
import { useEditor } from './store';
import { useImage } from './useImage';

export { applyTextTransform, composeFontStyle } from './textFit';

export type ClipShapeBinding = {
    shape: ShapeLayer;
    rect: { x: number; y: number; width: number; height: number };
    rotation: number;
};

type Props = {
    layer: Layer;
    canvas: Canvas;
    draggable?: boolean;
    /**
     * Lock state resolved against the group ancestor chain. When true, this
     * layer behaves as locked even if its own `layer.locked` is unset.
     */
    effectivelyLocked?: boolean;
    /**
     * When true, suppress the visual Konva node — used by the inline text
     * editor so the HTML textarea owns rendering while the user types.
     */
    hiddenForEdit?: boolean;
    devicePreset?: DevicePresetDto;
    /**
     * When this layer is an image bound to a ShapeLayer via `clipShapeId`, the
     * image still renders at its own rect/rotation; the shape's outline acts
     * purely as a mask (Pattern B). The bound image keeps its own anchors,
     * rotation, selection and drag — moving it reveals different regions of
     * the mask.
     */
    clipShapeBinding?: ClipShapeBinding;
    onSelect?: (id: string, additive: boolean) => void;
    onDragMove?: (id: string, node: Konva.Node) => void;
    onDragEnd?: (id: string, node: Konva.Node) => void;
    onTransform?: (id: string, node: Konva.Node) => void;
    onTransformEnd?: (id: string, node: Konva.Node) => void;
    /**
     * Hover-out / hover-in notification. Lets the parent paint a soft accent
     * outline at the hovered layer's rect, so users can see what they'd
     * select before clicking. Only wired for the active screen — inactive
     * screens swallow mouse events at the screen-level hit rect anyway.
     */
    onHoverChange?: (id: string | null) => void;
    /** Triggered when the empty-frame "Add screenshot" CTA is clicked. Only
     *  fires when the parent passed this callback — i.e. the main editor
     *  canvas. AllFactorsStrip + export render path don't pass it, so the
     *  CTA stays out of thumbnails / exports. */
    onRequestFrameScreenUpload?: (layerId: string) => void;
    /**
     * Flat array of every layer in this screen (master-resolved + extras +
     * override-merged). Callout layers walk this list to resolve their
     * `sourceLayerId`. Other layer types ignore it. Optional for backward
     * compat with callsites that don't render callouts (legacy AllFactorsStrip
     * thumbnails); when absent, callouts render as their orphan placeholder.
     */
    siblings?: Layer[];
};

/**
 * Build an ellipse / arc / donut / ring-segment path centered at (cx, cy). UI
 * conventions: startAngle 0° = 12 o'clock, clockwise-positive. Canvas 2D uses
 * radians from 3 o'clock with y-axis flipped so clockwise is positive angle.
 */
export function buildEllipsePath(
    ctx: CanvasRenderingContext2D,
    cx: number,
    cy: number,
    rx: number,
    ry: number,
    startAngleDeg: number,
    sweepAngleDeg: number,
    innerRatio: number,
): void {
    const rxSafe = Math.max(0.01, rx);
    const rySafe = Math.max(0.01, ry);
    const rxInner = rxSafe * innerRatio;
    const ryInner = rySafe * innerRatio;
    const isFullSweep = Math.abs(sweepAngleDeg) >= 360 - 1e-6;
    const hasHole = innerRatio > 1e-6;
    const startRad = ((startAngleDeg - 90) * Math.PI) / 180;
    const endRad = startRad + (sweepAngleDeg * Math.PI) / 180;

    ctx.beginPath();
    if (isFullSweep && !hasHole) {
        ctx.ellipse(cx, cy, rxSafe, rySafe, 0, 0, Math.PI * 2);
        ctx.closePath();
        return;
    }
    if (isFullSweep && hasHole) {
        ctx.ellipse(cx, cy, rxSafe, rySafe, 0, 0, Math.PI * 2, false);
        ctx.moveTo(cx + rxInner, cy);
        ctx.ellipse(cx, cy, rxInner, ryInner, 0, 0, Math.PI * 2, true);
        ctx.closePath();
        return;
    }
    if (!hasHole) {
        // Pie slice — arc + lines to center for a closeable fill.
        ctx.moveTo(cx, cy);
        ctx.ellipse(cx, cy, rxSafe, rySafe, 0, startRad, endRad, false);
        ctx.closePath();
        return;
    }
    // Ring segment — outer arc forward, inner arc backward, closePath joins the caps.
    ctx.ellipse(cx, cy, rxSafe, rySafe, 0, startRad, endRad, false);
    ctx.ellipse(cx, cy, rxInner, ryInner, 0, endRad, startRad, true);
    ctx.closePath();
}

/**
 * Regular polygon vertices plotted on an ellipse with semi-axes rx, ry so the
 * shape stretches with the bounding box instead of collapsing to the smaller
 * axis. `sides` is clamped to 3..12. First vertex sits at 12 o'clock.
 */
export function buildPolygonPath(
    ctx: CanvasRenderingContext2D,
    cx: number,
    cy: number,
    rx: number,
    ry: number,
    sides: number,
): void {
    const n = Math.max(3, Math.min(12, Math.round(sides)));
    ctx.beginPath();
    for (let i = 0; i < n; i++) {
        const a = -Math.PI / 2 + (i * Math.PI * 2) / n;
        const x = cx + rx * Math.cos(a);
        const y = cy + ry * Math.sin(a);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
    }
    ctx.closePath();
}

/**
 * Star with alternating outer/inner vertices plotted on ellipses (rx, ry) and
 * (rx*innerRatio, ry*innerRatio). `points` clamped to 3..12, `innerRatio` to
 * 0.1..0.9. First outer point at 12 o'clock.
 */
export function buildStarPath(
    ctx: CanvasRenderingContext2D,
    cx: number,
    cy: number,
    rx: number,
    ry: number,
    points: number,
    innerRatio: number,
): void {
    const n = Math.max(3, Math.min(12, Math.round(points)));
    const inner = Math.max(0.1, Math.min(0.9, innerRatio));
    const rxi = rx * inner;
    const ryi = ry * inner;
    ctx.beginPath();
    for (let i = 0; i < n * 2; i++) {
        const a = -Math.PI / 2 + (i * Math.PI) / n;
        const r = i % 2 === 0 ? 1 : 0;
        const rxv = r === 0 ? rxi : rx;
        const ryv = r === 0 ? ryi : ry;
        const x = cx + rxv * Math.cos(a);
        const y = cy + ryv * Math.sin(a);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
    }
    ctx.closePath();
}

/**
 * Trace a pen-tool path. `nodes` are fractions of the bbox (0..1); we scale
 * into local (width, height) space so the path stretches with the layer rect.
 * Smooth anchors carry `handleIn` / `handleOut` as offsets (also fractions of
 * bbox) relative to the anchor; missing = corner (straight) segment.
 */
export function buildPathFromNodes(
    ctx: CanvasRenderingContext2D,
    nodes: Array<import('@/types/layout').PathNode>,
    width: number,
    height: number,
    closed: boolean,
): void {
    if (nodes.length === 0) {
        ctx.beginPath();
        return;
    }
    const sx = (n: { x: number; y: number }) => n.x * width;
    const sy = (n: { x: number; y: number }) => n.y * height;
    ctx.beginPath();
    ctx.moveTo(sx(nodes[0]), sy(nodes[0]));
    for (let i = 1; i < nodes.length; i++) {
        const prev = nodes[i - 1];
        const curr = nodes[i];
        const hOut = prev.handleOut;
        const hIn = curr.handleIn;
        if (hOut || hIn) {
            const c1x = (prev.x + (hOut?.x ?? 0)) * width;
            const c1y = (prev.y + (hOut?.y ?? 0)) * height;
            const c2x = (curr.x + (hIn?.x ?? 0)) * width;
            const c2y = (curr.y + (hIn?.y ?? 0)) * height;
            ctx.bezierCurveTo(c1x, c1y, c2x, c2y, sx(curr), sy(curr));
        } else {
            ctx.lineTo(sx(curr), sy(curr));
        }
    }
    if (closed && nodes.length >= 2) {
        const last = nodes[nodes.length - 1];
        const first = nodes[0];
        const hOut = last.handleOut;
        const hIn = first.handleIn;
        if (hOut || hIn) {
            const c1x = (last.x + (hOut?.x ?? 0)) * width;
            const c1y = (last.y + (hOut?.y ?? 0)) * height;
            const c2x = (first.x + (hIn?.x ?? 0)) * width;
            const c2y = (first.y + (hIn?.y ?? 0)) * height;
            ctx.bezierCurveTo(c1x, c1y, c2x, c2y, sx(first), sy(first));
        }
        ctx.closePath();
    }
}

/**
 * Resolve a stroke dash preset into Konva `dash`/`lineCap` props, scaled from
 * stroke width so the pattern stays visually consistent as width changes.
 * Dotted uses a near-zero segment + round caps so dashes render as round dots.
 */
function dashConfig(
    dash: 'solid' | 'dashed' | 'dotted' | undefined,
    strokeWidth: number,
    dashOffset?: number,
    dashGap?: number,
): { dash?: number[]; lineCap?: 'butt' | 'round' | 'square'; dashOffset?: number } {
    if (!dash || dash === 'solid' || strokeWidth <= 0) return {};
    const offsetProp =
        typeof dashOffset === 'number' && Number.isFinite(dashOffset) && dashOffset !== 0
            ? { dashOffset }
            : {};
    // `dashGap` is a multiplier on strokeWidth controlling space between
    // dashes/dots. Default 2 preserves the historical Konva pattern.
    // Clamp to a sane range so a stray input doesn't render an invisible
    // (gap=0 → solid) or a useless (gap=50 → one dot per screen) pattern.
    const gapMult = typeof dashGap === 'number' && Number.isFinite(dashGap)
        ? Math.max(0.5, Math.min(10, dashGap))
        : 2;
    if (dash === 'dashed') return { dash: [strokeWidth * 3, strokeWidth * gapMult], ...offsetProp };
    return { dash: [0.01, strokeWidth * gapMult], lineCap: 'round', ...offsetProp };
}

/**
 * Resolve the layer's source image URL. When `worldWidth` is supplied, the
 * caller is hinting the rendered size and we route through `/media/thumb`
 * for a downsampled variant (massive win on cloud-stored 3000px+ PNGs that
 * render at a fraction of their native size in the editor).
 *
 * We deliberately do NOT roll DPR into the hint. `worldWidth` is already
 * canvas-space width, which at fit/moderate zoom exceeds the on-screen pixel
 * footprint — multiplying by DPR pinned every screenshot to the top thumb
 * bucket (1600px) and blew GPU texture memory on multi-screen decks. Using
 * the bare world width caps typical screenshots one bucket lower with no
 * visible softness (the export path renders full-res regardless).
 *
 * We also cap the request at MAX_CANVAS_THUMB_WIDTH. Tablet canvases (iPad
 * 2064-wide, Android tablet 1848) otherwise requested the top 1600px bucket
 * for every full-bleed image, so a 9-screen tablet deck decoded + rasterized
 * (into the inactive-screen cache) + blitted ~2.5× more pixels than a phone —
 * the source of the tablet setup lag + scroll sluggishness. 1200 is plenty
 * for an on-canvas preview; export still renders native-res.
 */
const MAX_CANVAS_THUMB_WIDTH = 1200;
const rasterUrlFor = (layer: Layer, worldWidth?: number): string | undefined => {
    const width = worldWidth
        ? Math.min(Math.ceil(worldWidth), MAX_CANVAS_THUMB_WIDTH)
        : undefined;
    const opts = width ? { width } : undefined;
    if (layer.type === 'image') return mediaUrl(layer.storagePath, opts);
    if (layer.type === 'background') return mediaUrl(layer.image?.storagePath, opts);
    if (layer.type === 'frame') return mediaUrl(layer.screen?.storagePath, opts);
    return undefined;
};

function fitBoxDims(
    srcW: number,
    srcH: number,
    dstW: number,
    dstH: number,
    mode: 'cover' | 'contain',
    zoom = 1,
    offsetX = 0,
    offsetY = 0,
): { x: number; y: number; width: number; height: number } {
    const base =
        mode === 'cover'
            ? Math.max(dstW / srcW, dstH / srcH)
            : Math.min(dstW / srcW, dstH / srcH);
    const scale = base * zoom;
    const w = srcW * scale;
    const h = srcH * scale;
    return {
        x: (dstW - w) / 2 + offsetX * dstW,
        y: (dstH - h) / 2 + offsetY * dstH,
        width: w,
        height: h,
    };
}

function fitBox(
    image: HTMLImageElement,
    width: number,
    height: number,
    mode: 'cover' | 'contain',
    zoom = 1,
    offsetX = 0,
    offsetY = 0,
): { x: number; y: number; width: number; height: number } {
    return fitBoxDims(image.width, image.height, width, height, mode, zoom, offsetX, offsetY);
}

const LayerNodeInner = forwardRef<Konva.Node, Props>(function LayerNode(
    {
        layer,
        canvas,
        draggable = false,
        effectivelyLocked,
        hiddenForEdit,
        devicePreset,
        clipShapeBinding,
        onSelect,
        onDragMove,
        onDragEnd,
        onTransform,
        onTransformEnd,
        onRequestFrameScreenUpload,
        onHoverChange,
        siblings,
    },
    ref,
) {
    // Hoisted ABOVE the image fetch so we can derive a width hint from
    // the layer's resolved rect — drives the thumb variant width passed
    // to `mediaUrl`. resolveAnchors is pure so calling it before any
    // hook / early return is safe.
    const anchoredRect = resolveAnchors(layer.anchors, canvas);
    const rasterUrl = rasterUrlFor(layer, anchoredRect.width);
    const { image } = useImage(rasterUrl);
    // Locale-aware text rendering: pulls `content[activeLocale]` with a
    // fall-through to `content.default`. Stays in sync with the toolbar
    // LocaleSwitcher without prop-drilling.
    const activeLocale = useEditor((s) => s.activeLocale);
    const defaultLocale = useEditor((s) => s.defaultLocale);
    // Writing direction. Drives semantic-align flipping ('start' →
    // 'right' for RTL) so Spanish + Arabic share the same authored
    // `align: 'start'` value but visually anchor to the correct edge.
    const localeDirection = useActiveLocaleDirection();
    // clipShape-binding coexists with the image's own anchors/rotation — the
    // shape acts purely as a mask (Pattern B). The bound image keeps its own
    // selection + drag and moves freely inside the mask.
    const isBoundToClipShape = layer.type === 'image' && clipShapeBinding != null;
    const rect = anchoredRect;

    // Background-image filter cache: Konva needs an explicit cache() whenever
    // blur radius, the image, the fit mode, or the rendered size changes —
    // without re-caching on a size change the node draws the stale raster at
    // the wrong dimensions (e.g. empty on a contain→cover flip).
    const bgImageRef = useRef<Konva.Image>(null);
    const bgBlur = layer.type === 'background' ? layer.image?.blur ?? 0 : 0;
    const bgFit = layer.type === 'background' ? layer.image?.fit ?? null : null;
    const bgZoom = layer.type === 'background' ? layer.image?.zoom ?? 1 : 1;
    const bgTint = layer.type === 'background' ? layer.image?.tint ?? null : null;
    const bgTintColor = bgTint && bgTint.amount > 0 ? bgTint.color : null;
    const bgTintAmount = bgTint && bgTint.amount > 0 ? bgTint.amount : 0;
    useEffect(() => {
        const node = bgImageRef.current;
        if (!node) return;
        // Both blur and the RGBA tint need the node cached into an offscreen
        // raster before the filter takes effect.
        if (bgBlur > 0 || bgTintAmount > 0) {
            node.cache();
        } else {
            node.clearCache();
        }
    }, [bgBlur, bgTintColor, bgTintAmount, image, bgFit, bgZoom, rect.width, rect.height]);

    // Shape Gaussian blur: cache the primary shape node with the Blur filter
    // applied (Konva needs cache() before filters take effect). Mirrors the
    // background-image blur above and the export renderer in
    // `editor/export/renderScreen.ts`. `offset` pads the cache so the soft
    // blur halo isn't clipped at the shape's tight bounding box. The primary
    // node is captured via `setPrimaryShapeRef` (the same node the Transformer
    // finds by id), so blur, selection, and drag all target one node.
    const shapeNodeRef = useRef<Konva.Node | null>(null);
    const shapeBlur =
        layer.type === 'shape' ? Math.max(0, layer.style?.blur ?? 0) : 0;
    // A simple solid-fill ellipse with blur renders through the NATIVE canvas
    // blur (`ctx.filter`) for CSS-identical, rim-free softening — it must NOT
    // also go through the Konva-filter cache below (that would double-blur + rim).
    const shapeNativeBlur =
        layer.type === 'shape' &&
        shapeBlur > 0 &&
        layer.shape === 'ellipse' &&
        (layer.sweepAngle ?? 360) >= 360 &&
        (layer.innerRatio ?? 0) <= 0 &&
        (layer.style?.stroke?.width ?? 0) <= 0 &&
        solidFillValue(layer.style?.fill) !== null;
    const setPrimaryShapeRef = useCallback(
        (node: Konva.Node | null) => {
            shapeNodeRef.current = node;
            if (typeof ref === 'function') ref(node);
            else if (ref) (ref as React.MutableRefObject<Konva.Node | null>).current = node;
        },
        [ref],
    );
    const shapeCacheKey =
        layer.type === 'shape'
            ? JSON.stringify({
                  b: shapeBlur,
                  s: layer.style,
                  sh: layer.shape,
                  w: Math.round(rect.width),
                  h: Math.round(rect.height),
                  sa: layer.startAngle,
                  sw: layer.sweepAngle,
                  ir: layer.innerRatio,
                  sd: layer.sides,
                  sp: layer.starPoints,
                  cl: layer.closed,
                  nd: layer.nodes,
              })
            : '';
    // SPIKE — inline rich text. Attach a `charRenderFunc` when the text layer
    // carries `richRuns`, so per-word colour/bold paint on the SAME Text node.
    const textNodeRef = useRef<Konva.Text | null>(null);
    // Per-locale runs: default locale → richRuns, overrides → richRunsByLocale.
    const richRuns =
        layer.type === 'text' ? resolveRichRuns(layer, activeLocale, defaultLocale) : undefined;
    const richBaseColor = layer.type === 'text' ? layer.color : '#000';
    const setTextRef = useCallback(
        (node: Konva.Text | null) => {
            textNodeRef.current = node;
            if (typeof ref === 'function') ref(node);
            else if (ref) (ref as React.MutableRefObject<Konva.Node | null>).current = node;
        },
        [ref],
    );
    // Inline rich-text colour/bold rides Konva's per-glyph `charRenderFunc`,
    // which renders each grapheme with a standalone `fillText` and so breaks
    // complex-script shaping (Devanagari conjuncts split, Arabic disconnects,
    // matras detach) the same way non-zero letterSpacing does. Suppress the rich
    // path for those scripts — the text renders shaped in the base colour,
    // matching the existing RTL fallback. Accent emphasis isn't representable for
    // complex scripts on a Konva canvas; correct shaping wins over a coloured word.
    const richTextComplexScript =
        layer.type === 'text' && isComplexScript(resolveLocalizedText(layer.content, activeLocale));
    const richActive = !!richRuns && richRuns.length > 0 && !richTextComplexScript;
    const richKey = richActive ? `${JSON.stringify(richRuns)}|${richBaseColor}` : '';
    const wasRichRef = useRef(false);
    useEffect(() => {
        const node = textNodeRef.current;
        if (!node) return;
        if (richActive && richRuns) {
            node.charRenderFunc(makeRichCharRenderFunc(node, richRuns, richBaseColor));
            wasRichRef.current = true;
        } else {
            node.charRenderFunc(null);
            // charRenderFunc mutated `attrs.fill` per glyph; react-konva won't
            // re-apply the base `fill` prop (its value didn't change), so the
            // text would stay in the last run's colour. Restore base on clear.
            // Only for solid-fill text (gradient text doesn't use richRuns).
            if (wasRichRef.current && layer.type === 'text' && !layer.fill) {
                node.fill(richBaseColor);
            }
            wasRichRef.current = false;
        }
        node.getLayer()?.batchDraw();
    }, [richKey, richActive, richRuns, richBaseColor, layer]);

    useEffect(() => {
        const node = shapeNodeRef.current;
        if (!node) return;
        if (shapeBlur > 0 && !shapeNativeBlur) {
            node.filters(BLUR_FILTERS);
            node.setAttr('blurRadius', shapeBlur);
            node.cache({ offset: Math.ceil(shapeBlur * 2) + 2 });
        } else {
            node.clearCache();
            node.filters([]);
        }
        node.getLayer()?.batchDraw();
    }, [shapeBlur, shapeCacheKey, shapeNativeBlur]);

    if (layer.visible === false) return null;
    if (hiddenForEdit) return null;

    const rotation = layer.rotation ?? 0;
    const opacity = layer.style?.opacity ?? 1;
    const blendMode = layer.style?.blendMode;
    const locked = effectivelyLocked ?? layer.locked === true;

    const commonShape = {
        id: layer.id,
        name: `layer-${layer.id}`,
        x: rect.x,
        y: rect.y,
        rotation,
        opacity,
        // Konva draws any fill+stroke (or shadowed) shape to an OFFSCREEN buffer
        // first for pixel-perfect edges, then composites — roughly doubling the
        // per-node cost on every redraw (pan / zoom / select). Screenshot decks
        // are opaque frames + text where the difference is invisible, so turn it
        // off for a large draw-time win on the live canvas. The export renderer
        // (renderScreen.ts) keeps the default for pixel-perfect output.
        perfectDrawEnabled: false,
        shadowForStrokeEnabled: false,
        globalCompositeOperation:
            blendMode && blendMode !== 'normal' ? blendMode : undefined,
        // Background is full-bleed — dragging it would rewrite its anchors
        // into concrete coords and break the canvas-wide bounds. Selection is
        // still fine; drag/transform aren't. In callout mode, suppress drag
        // so the user can draw the source/display rect over the layer.
        draggable:
            draggable &&
            !locked &&
            layer.type !== 'background' &&
            useEditor.getState().calloutMode === 'idle' &&
            // Block drag while THIS image is in crop mode — otherwise the
            // user's crop-handle drag bubbles to the layer and shifts the
            // image. Crop mode only applies to image layers; gate by id.
            useEditor.getState().cropModeLayerId !== layer.id,
        // Clip-shape binding keeps the image clickable — image keeps its own
        // selection + drag + keyboard nudge and moves freely inside the mask.
        // Block all pointer events on the cropped image so the crop overlay
        // (rendered above it) is the sole drag target.
        listening: !locked && useEditor.getState().cropModeLayerId !== layer.id,
        onMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => {
            // In callout authoring mode, let the click bubble up to the stage
            // so handleStageMouseDown can capture the source-rect drag. Without
            // this fallthrough, layer.onMouseDown's `cancelBubble = true` +
            // onSelect would intercept and the user would just select+drag the
            // layer instead of drawing the callout source.
            if (useEditor.getState().calloutMode !== 'idle') return;
            e.cancelBubble = true;
            onSelect?.(layer.id, e.evt.shiftKey);
        },
        onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => onDragMove?.(layer.id, e.target),
        onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => onDragEnd?.(layer.id, e.target),
        onTransform: (e: Konva.KonvaEventObject<Event>) => onTransform?.(layer.id, e.target),
        onTransformEnd: (e: Konva.KonvaEventObject<Event>) => onTransformEnd?.(layer.id, e.target),
        onMouseEnter: () => onHoverChange?.(layer.id),
        onMouseLeave: () => onHoverChange?.(null),
    };

    if (layer.type === 'background') {
        const fill = colorToKonvaFill(layer.fill, rect.width, rect.height);
        const bgFit = layer.image && image ? layer.image.fit : null;
        const zoom = layer.image?.zoom ?? 1;
        const offX = layer.image?.offsetX ?? 0;
        const offY = layer.image?.offsetY ?? 0;
        const imageOpacity = layer.image?.opacity ?? 1;
        const fitRect =
            image && (bgFit === 'cover' || bgFit === 'contain')
                ? fitBox(image, rect.width, rect.height, bgFit, zoom, offX, offY)
                : null;
        const overlay = layer.image?.overlay;

        // Blur + RGBA-tint filters on the bg image (both need cache(), wired in
        // the bgImageRef effect above). The tint recolours a white abstract to an
        // on-brand shade; it preserves the image's alpha.
        const bgFilters: Array<typeof Konva.Filters.Blur> = [];
        const bgFilterAttrs: Record<string, number> = {};
        if (bgBlur > 0) {
            bgFilters.push(Konva.Filters.Blur);
            bgFilterAttrs.blurRadius = bgBlur;
        }
        if (bgTintColor && bgTintAmount > 0) {
            const { r, g, b } = parseHexColor(bgTintColor);
            bgFilters.push(Konva.Filters.RGBA);
            bgFilterAttrs.red = r;
            bgFilterAttrs.green = g;
            bgFilterAttrs.blue = b;
            bgFilterAttrs.alpha = Math.min(1, Math.max(0, bgTintAmount));
        }

        return (
            <Group
                ref={ref as React.Ref<Konva.Group>}
                {...commonShape}
                width={rect.width}
                height={rect.height}
                // Clip cover-sized images to the background bounds. Contain
                // always fits inside so the clip is a no-op there, but keeping
                // it on unconditionally avoids re-creating the Group config.
                clipX={0}
                clipY={0}
                clipWidth={rect.width}
                clipHeight={rect.height}
            >
                {/* Base fill carries the Group's hit region — without a
                    listening child, Konva's hit-test recurses into nothing and
                    clicks fall through to the stage, which clears selection. */}
                <Rect width={rect.width} height={rect.height} {...fill} />
                {image && bgFit === 'tile' && (
                    <Rect
                        width={rect.width}
                        height={rect.height}
                        fillPatternImage={image}
                        fillPatternRepeat="repeat"
                        fillPatternScale={{ x: zoom, y: zoom }}
                        // Negative sign so a positive offsetX visually shifts
                        // the tile pattern to the right (matches how cover /
                        // contain interpret the same control).
                        fillPatternOffset={{ x: -offX * rect.width, y: -offY * rect.height }}
                        opacity={imageOpacity}
                        listening={false}
                    />
                )}
                {image && fitRect && (
                    <KonvaImage
                        ref={bgImageRef}
                        image={image}
                        x={fitRect.x}
                        y={fitRect.y}
                        width={fitRect.width}
                        height={fitRect.height}
                        filters={bgFilters.length ? bgFilters : undefined}
                        {...bgFilterAttrs}
                        opacity={imageOpacity}
                        listening={false}
                    />
                )}
                {image && overlay && overlay.opacity > 0 && (
                    <Rect
                        width={rect.width}
                        height={rect.height}
                        fill={overlay.color}
                        opacity={overlay.opacity}
                        listening={false}
                    />
                )}
            </Group>
        );
    }

    if (layer.type === 'shape') {
        const fill = colorToKonvaFill(layer.style?.fill, rect.width, rect.height);
        const stroke = layer.style?.stroke;
        const shadows = resolveShadows(layer.style);
        // Shadow renders BEHIND the shape only — applied to the fill via
        // `withShadows()` below. The inside/outside-stroke variants used
        // to spread the primary shadow onto the stroke shape too; that
        // caused the shadow to render twice (once behind the fill, once
        // behind each dash of the stroke), which on dashed strokes
        // leaked a dark band through the gaps. Matches the export
        // renderer in `editor/export/renderScreen.ts` which never applies
        // shadows to its inside/outside stroke shape.
        const strokeDash = dashConfig(stroke?.dash, stroke?.width ?? 0, stroke?.dashOffset, stroke?.dashGap);

        // Filled vector graphic (laurel wreath etc.) — fills the box with the
        // path silhouette in style.fill. Mirrors the export twin in renderScreen.ts.
        if (layer.vectorPaths && layer.vectorPaths.length > 0) {
            const vFill = layer.style?.fill;
            const [vbW, vbH] = layer.vectorViewBox ?? [24, 24];
            const vPaths = layer.vectorPaths;
            const vRule = layer.vectorFillRule ?? 'nonzero';
            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <KonvaShape
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Shape>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    x={rect.x}
                    y={rect.y}
                    width={rect.width}
                    height={rect.height}
                    {...shadowProps}
                    sceneFunc={(ctx) => {
                        drawVectorGraphic(
                            ctx as unknown as CanvasRenderingContext2D,
                            rect.width,
                            rect.height,
                            vPaths,
                            vbW,
                            vbH,
                            vFill,
                            vRule,
                        );
                    }}
                />
            ));
        }

        // Icon BADGE — a rounded-rect tile + stroked vector glyph. Bypasses the
        // normal shape geometry; mirrors the export renderer in renderScreen.ts.
        if (layer.iconPaths && layer.iconPaths.length > 0) {
            const tileColor = solidFillValue(layer.style?.fill) ?? '#3B82F6';
            const glyphColor = layer.iconColor ?? '#ffffff';
            const radius = layer.style?.radius ?? Math.min(rect.width, rect.height) * 0.28;
            const gsw = stroke?.width ?? 2;
            const iconPaths = layer.iconPaths;
            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <KonvaShape
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Shape>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    x={rect.x}
                    y={rect.y}
                    width={rect.width}
                    height={rect.height}
                    {...shadowProps}
                    sceneFunc={(ctx) => {
                        drawIconBadge(
                            ctx as unknown as CanvasRenderingContext2D,
                            rect.width,
                            rect.height,
                            iconPaths,
                            tileColor,
                            glyphColor,
                            radius,
                            gsw,
                        );
                    }}
                />
            ));
        }

        if (layer.shape === 'ellipse') {
            const startAngle = layer.startAngle ?? 0;
            const sweepAngle = Math.max(0, Math.min(360, layer.sweepAngle ?? 360));
            const innerRatio = Math.max(0, Math.min(0.95, layer.innerRatio ?? 0));
            const strokePos = stroke?.position ?? 'center';
            const sw = stroke?.width ?? 0;
            const isSimple = sweepAngle >= 360 && innerRatio <= 0;
            const usePositionedStroke =
                sw > 0 && stroke != null && strokePos !== 'center';

            if (isSimple && !usePositionedStroke) {
                // Native-blur fast path: solid disc + browser Gaussian blur
                // (CSS-identical, no StackBlur rim). Node opacity layers on top,
                // exactly like CSS `opacity` after `filter: blur()`.
                if (shapeNativeBlur) {
                    const solid = solidFillValue(layer.style?.fill) ?? '#000';
                    return (
                        <KonvaShape
                            ref={setPrimaryShapeRef as React.Ref<Konva.Shape>}
                            {...commonShape}
                            x={rect.x}
                            y={rect.y}
                            width={rect.width}
                            height={rect.height}
                            sceneFunc={
                                nativeBlurEllipseScene(
                                    rect.width,
                                    rect.height,
                                    solid,
                                    shapeBlur,
                                ) as unknown as (ctx: Konva.Context, node: Konva.Shape) => void
                            }
                        />
                    );
                }
                return withShadows(shadows, (shadowProps, isPrimary, key) => (
                    <Ellipse
                        key={key ?? undefined}
                        ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Ellipse>) : undefined}
                        {...commonShape}
                        listening={isPrimary ? commonShape.listening : false}
                        x={rect.x + rect.width / 2}
                        y={rect.y + rect.height / 2}
                        radiusX={rect.width / 2}
                        radiusY={rect.height / 2}
                        {...fill}
                        {...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height)}
                        strokeWidth={stroke?.width}
                        {...strokeDash}
                        {...shadowProps}
                    />
                ));
            }

            const rx = rect.width / 2;
            const ry = rect.height / 2;
            const cx = rect.width / 2;
            const cy = rect.height / 2;

            if (usePositionedStroke) {
                // Approximate inside/outside by shrinking/growing the stroke
                // ellipse's semi-axes by sw/2. Exact only for circles, but
                // visually close for ellipses at typical stroke widths.
                const sign = strokePos === 'inside' ? -1 : 1;
                const rxStroke = Math.max(0.01, rx + (sign * sw) / 2);
                const ryStroke = Math.max(0.01, ry + (sign * sw) / 2);
                return (
                    <Group
                        ref={setPrimaryShapeRef as React.Ref<Konva.Group>}
                        {...commonShape}
                        x={rect.x + rect.width / 2}
                        y={rect.y + rect.height / 2}
                        offsetX={rect.width / 2}
                        offsetY={rect.height / 2}
                        width={rect.width}
                        height={rect.height}
                    >
                        {withShadows(shadows, (shadowProps, _isPrimary, key) => (
                            <KonvaShape
                                key={key ?? undefined}
                                width={rect.width}
                                height={rect.height}
                                sceneFunc={(ctx, node) => {
                                    buildEllipsePath(
                                        ctx as unknown as CanvasRenderingContext2D,
                                        cx,
                                        cy,
                                        rx,
                                        ry,
                                        startAngle,
                                        sweepAngle,
                                        innerRatio,
                                    );
                                    ctx.fillStrokeShape(node);
                                }}
                                {...fill}
                                {...shadowProps}
                            />
                        ))}
                        <KonvaShape
                            width={rect.width}
                            height={rect.height}
                            sceneFunc={(ctx, node) => {
                                buildEllipsePath(
                                    ctx as unknown as CanvasRenderingContext2D,
                                    cx,
                                    cy,
                                    rxStroke,
                                    ryStroke,
                                    startAngle,
                                    sweepAngle,
                                    innerRatio,
                                );
                                ctx.fillStrokeShape(node);
                            }}
                            fillEnabled={false}
                            {...colorToKonvaStroke(strokeColorAsColor(stroke.color), rect.width, rect.height)}
                            strokeWidth={sw}
                            {...strokeDash}
                            listening={false}
                        />
                    </Group>
                );
            }

            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <KonvaShape
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Shape>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    x={rect.x + rect.width / 2}
                    y={rect.y + rect.height / 2}
                    offsetX={rect.width / 2}
                    offsetY={rect.height / 2}
                    width={rect.width}
                    height={rect.height}
                    sceneFunc={(ctx, node) => {
                        buildEllipsePath(
                            ctx as unknown as CanvasRenderingContext2D,
                            cx,
                            cy,
                            rx,
                            ry,
                            startAngle,
                            sweepAngle,
                            innerRatio,
                        );
                        ctx.fillStrokeShape(node);
                    }}
                    {...fill}
                    {...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height)}
                    strokeWidth={stroke?.width}
                    {...strokeDash}
                    {...shadowProps}
                />
            ));
        }

        if (layer.shape === 'line') {
            const lineW = stroke?.width ?? 2;
            const lineDash = dashConfig(stroke?.dash, lineW, stroke?.dashOffset, stroke?.dashGap);
            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <Line
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Line>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    width={rect.width}
                    height={rect.height}
                    points={[0, rect.height / 2, rect.width, rect.height / 2]}
                    {...colorToKonvaStroke(
                        strokeColorAsColor(stroke?.color) ?? { type: 'solid', value: '#0F172A' },
                        rect.width,
                        rect.height,
                    )}
                    strokeWidth={lineW}
                    lineCap={lineDash.lineCap ?? stroke?.lineCap ?? 'round'}
                    dash={lineDash.dash}
                    hitStrokeWidth={Math.max(12, lineW + 10)}
                    {...shadowProps}
                />
            ));
        }

        if (layer.shape === 'arrow') {
            const strokeW = stroke?.width ?? 2;
            const strokeSolid =
                typeof stroke?.color === 'string'
                    ? stroke.color
                    : stroke?.color?.type === 'solid'
                      ? stroke.color.value
                      : (stroke?.color?.stops?.[0]?.color ?? '#0F172A');
            const head = Math.max(4, strokeW * 3 * (layer.arrowHead ?? 1));
            const arrowDash = dashConfig(stroke?.dash, strokeW, stroke?.dashOffset, stroke?.dashGap);
            const arrowAt = layer.arrowAt ?? 'end';
            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <Arrow
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Arrow>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    width={rect.width}
                    height={rect.height}
                    points={[0, rect.height / 2, rect.width, rect.height / 2]}
                    {...colorToKonvaStroke(
                        strokeColorAsColor(stroke?.color) ?? { type: 'solid', value: '#0F172A' },
                        rect.width,
                        rect.height,
                    )}
                    strokeWidth={strokeW}
                    // Arrow.fill paints the head — Konva Arrow heads don't
                    // support gradients, so fall back to the first stop color
                    // (or the solid value) for the head paint.
                    fill={strokeSolid}
                    pointerLength={head}
                    pointerWidth={head}
                    pointerAtBeginning={arrowAt === 'start' || arrowAt === 'both'}
                    pointerAtEnding={arrowAt === 'end' || arrowAt === 'both'}
                    // Butt cap keeps the line flush with the triangle tip;
                    // round join kills the miter spike past the tip corner.
                    // Dotted needs `round` to stretch zero-length dashes into
                    // visible dots — dashConfig returns that and wins here.
                    lineCap={arrowDash.lineCap ?? 'butt'}
                    lineJoin={stroke?.lineJoin ?? 'round'}
                    hitStrokeWidth={Math.max(12, strokeW + 10)}
                    dash={arrowDash.dash}
                    {...shadowProps}
                />
            ));
        }

        if (layer.shape === 'polygon') {
            const sides = Math.max(3, Math.min(12, layer.sides ?? 6));
            const rx = rect.width / 2;
            const ry = rect.height / 2;
            const cx = rect.width / 2;
            const cy = rect.height / 2;
            const strokePosPoly = stroke?.position ?? 'center';
            const swPoly = stroke?.width ?? 0;
            const usePositionedStrokePoly =
                swPoly > 0 && stroke != null && strokePosPoly !== 'center';

            if (usePositionedStrokePoly) {
                const sign = strokePosPoly === 'inside' ? -1 : 1;
                const rxStroke = Math.max(0.01, rx + (sign * swPoly) / 2);
                const ryStroke = Math.max(0.01, ry + (sign * swPoly) / 2);
                return (
                    <Group
                        ref={setPrimaryShapeRef as React.Ref<Konva.Group>}
                        {...commonShape}
                        x={rect.x + rect.width / 2}
                        y={rect.y + rect.height / 2}
                        offsetX={rect.width / 2}
                        offsetY={rect.height / 2}
                        width={rect.width}
                        height={rect.height}
                    >
                        {withShadows(shadows, (shadowProps, _isPrimary, key) => (
                            <KonvaShape
                                key={key ?? undefined}
                                width={rect.width}
                                height={rect.height}
                                sceneFunc={(ctx, node) => {
                                    buildPolygonPath(
                                        ctx as unknown as CanvasRenderingContext2D,
                                        cx,
                                        cy,
                                        rx,
                                        ry,
                                        sides,
                                    );
                                    ctx.fillStrokeShape(node);
                                }}
                                {...fill}
                                {...shadowProps}
                            />
                        ))}
                        <KonvaShape
                            width={rect.width}
                            height={rect.height}
                            sceneFunc={(ctx, node) => {
                                buildPolygonPath(
                                    ctx as unknown as CanvasRenderingContext2D,
                                    cx,
                                    cy,
                                    rxStroke,
                                    ryStroke,
                                    sides,
                                );
                                ctx.fillStrokeShape(node);
                            }}
                            fillEnabled={false}
                            {...colorToKonvaStroke(strokeColorAsColor(stroke.color), rect.width, rect.height)}
                            strokeWidth={swPoly}
                            lineJoin={stroke?.lineJoin}
                            {...strokeDash}
                            listening={false}
                        />
                    </Group>
                );
            }

            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <KonvaShape
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Shape>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    // Node positioned at the bbox CENTER (for rotation +
                    // scale to feel natural) but the local origin is
                    // shifted to the center via offsetX/Y so the Konva
                    // bbox top-left lands at (rect.x, rect.y) — the
                    // Transformer reads this to draw selection handles
                    // around the visible hexagon. Without the offsets,
                    // handles drew at +half-size down-right of the
                    // actual polygon.
                    x={rect.x + rect.width / 2}
                    y={rect.y + rect.height / 2}
                    offsetX={rect.width / 2}
                    offsetY={rect.height / 2}
                    width={rect.width}
                    height={rect.height}
                    sceneFunc={(ctx, node) => {
                        buildPolygonPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            cx,
                            cy,
                            rx,
                            ry,
                            sides,
                        );
                        ctx.fillStrokeShape(node);
                    }}
                    {...fill}
                    {...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height)}
                    strokeWidth={stroke?.width}
                    lineJoin={stroke?.lineJoin}
                    {...strokeDash}
                    {...shadowProps}
                />
            ));
        }

        if (layer.shape === 'star') {
            const numPoints = Math.max(3, Math.min(12, layer.starPoints ?? 5));
            const innerRatio = Math.max(0.1, Math.min(0.9, layer.innerRatio ?? 0.4));
            const rx = rect.width / 2;
            const ry = rect.height / 2;
            const cx = rect.width / 2;
            const cy = rect.height / 2;
            const strokePosStar = stroke?.position ?? 'center';
            const swStar = stroke?.width ?? 0;
            const usePositionedStrokeStar =
                swStar > 0 && stroke != null && strokePosStar !== 'center';

            if (usePositionedStrokeStar) {
                const sign = strokePosStar === 'inside' ? -1 : 1;
                const rxStroke = Math.max(0.01, rx + (sign * swStar) / 2);
                const ryStroke = Math.max(0.01, ry + (sign * swStar) / 2);
                return (
                    <Group
                        ref={setPrimaryShapeRef as React.Ref<Konva.Group>}
                        {...commonShape}
                        x={rect.x + rect.width / 2}
                        y={rect.y + rect.height / 2}
                        offsetX={rect.width / 2}
                        offsetY={rect.height / 2}
                        width={rect.width}
                        height={rect.height}
                    >
                        {withShadows(shadows, (shadowProps, _isPrimary, key) => (
                            <KonvaShape
                                key={key ?? undefined}
                                width={rect.width}
                                height={rect.height}
                                sceneFunc={(ctx, node) => {
                                    buildStarPath(
                                        ctx as unknown as CanvasRenderingContext2D,
                                        cx,
                                        cy,
                                        rx,
                                        ry,
                                        numPoints,
                                        innerRatio,
                                    );
                                    ctx.fillStrokeShape(node);
                                }}
                                {...fill}
                                {...shadowProps}
                            />
                        ))}
                        <KonvaShape
                            width={rect.width}
                            height={rect.height}
                            sceneFunc={(ctx, node) => {
                                buildStarPath(
                                    ctx as unknown as CanvasRenderingContext2D,
                                    cx,
                                    cy,
                                    rxStroke,
                                    ryStroke,
                                    numPoints,
                                    innerRatio,
                                );
                                ctx.fillStrokeShape(node);
                            }}
                            fillEnabled={false}
                            {...colorToKonvaStroke(strokeColorAsColor(stroke.color), rect.width, rect.height)}
                            strokeWidth={swStar}
                            lineJoin={stroke?.lineJoin}
                            {...strokeDash}
                            listening={false}
                        />
                    </Group>
                );
            }

            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <KonvaShape
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Shape>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    // Same offsetX/Y pattern as the polygon branch — see
                    // comment there. Without these, the Transformer's
                    // selection bbox drew offset down-right by half the
                    // star's size.
                    x={rect.x + rect.width / 2}
                    y={rect.y + rect.height / 2}
                    offsetX={rect.width / 2}
                    offsetY={rect.height / 2}
                    width={rect.width}
                    height={rect.height}
                    sceneFunc={(ctx, node) => {
                        buildStarPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            cx,
                            cy,
                            rx,
                            ry,
                            numPoints,
                            innerRatio,
                        );
                        ctx.fillStrokeShape(node);
                    }}
                    {...fill}
                    {...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height)}
                    strokeWidth={stroke?.width}
                    lineJoin={stroke?.lineJoin}
                    {...strokeDash}
                    {...shadowProps}
                />
            ));
        }

        if (layer.shape === 'path') {
            const nodes = layer.nodes ?? [];
            const closed = layer.closed ?? false;
            // Closed paths can opt-out of stroke via the panel (sets
            // `style.stroke = undefined` when width goes to 0), so don't fall
            // back to a visible width here — render no stroke when absent.
            const pathStrokeW = stroke?.width ?? 0;
            const pathStrokeColor =
                strokeColorAsColor(stroke?.color) ?? { type: 'solid' as const, value: '#0F172A' };
            const strokePosPath = stroke?.position ?? 'center';
            const usePositionedStrokePath =
                closed && pathStrokeW > 0 && stroke != null && strokePosPath !== 'center';

            if (usePositionedStrokePath) {
                const sign = strokePosPath === 'inside' ? -1 : 1;
                const sx = rect.width > 0 ? (rect.width + sign * pathStrokeW) / rect.width : 1;
                const sy = rect.height > 0 ? (rect.height + sign * pathStrokeW) / rect.height : 1;
                const strokeNodes = nodes.map((n) => ({
                    x: 0.5 + (n.x - 0.5) * sx,
                    y: 0.5 + (n.y - 0.5) * sy,
                    handleIn: n.handleIn
                        ? { x: n.handleIn.x * sx, y: n.handleIn.y * sy }
                        : undefined,
                    handleOut: n.handleOut
                        ? { x: n.handleOut.x * sx, y: n.handleOut.y * sy }
                        : undefined,
                }));
                return (
                    <Group
                        ref={setPrimaryShapeRef as React.Ref<Konva.Group>}
                        {...commonShape}
                        width={rect.width}
                        height={rect.height}
                    >
                        {withShadows(shadows, (shadowProps, _isPrimary, key) => (
                            <KonvaShape
                                key={key ?? undefined}
                                width={rect.width}
                                height={rect.height}
                                sceneFunc={(ctx, node) => {
                                    buildPathFromNodes(
                                        ctx as unknown as CanvasRenderingContext2D,
                                        nodes,
                                        rect.width,
                                        rect.height,
                                        closed,
                                    );
                                    ctx.fillStrokeShape(node);
                                }}
                                {...fill}
                                {...shadowProps}
                            />
                        ))}
                        <KonvaShape
                            width={rect.width}
                            height={rect.height}
                            sceneFunc={(ctx, node) => {
                                buildPathFromNodes(
                                    ctx as unknown as CanvasRenderingContext2D,
                                    strokeNodes,
                                    rect.width,
                                    rect.height,
                                    closed,
                                );
                                ctx.fillStrokeShape(node);
                            }}
                            hitStrokeWidth={Math.max(12, pathStrokeW + 10)}
                            fillEnabled={false}
                            {...colorToKonvaStroke(pathStrokeColor, rect.width, rect.height)}
                            strokeWidth={pathStrokeW}
                            lineCap={stroke?.lineCap ?? 'round'}
                            lineJoin={stroke?.lineJoin ?? 'round'}
                            {...strokeDash}
                            listening={false}
                        />
                    </Group>
                );
            }

            return withShadows(shadows, (shadowProps, isPrimary, key) => (
                <KonvaShape
                    key={key ?? undefined}
                    ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Shape>) : undefined}
                    {...commonShape}
                    listening={isPrimary ? commonShape.listening : false}
                    width={rect.width}
                    height={rect.height}
                    sceneFunc={(ctx, node) => {
                        buildPathFromNodes(
                            ctx as unknown as CanvasRenderingContext2D,
                            nodes,
                            rect.width,
                            rect.height,
                            closed,
                        );
                        ctx.fillStrokeShape(node);
                    }}
                    hitStrokeWidth={Math.max(12, pathStrokeW + 10)}
                    {...(closed ? fill : { fillEnabled: false })}
                    {...colorToKonvaStroke(pathStrokeColor, rect.width, rect.height)}
                    strokeWidth={pathStrokeW}
                    lineCap={stroke?.lineCap ?? 'round'}
                    lineJoin={stroke?.lineJoin ?? 'round'}
                    {...strokeDash}
                    {...shadowProps}
                />
            ));
        }

        const baseRadius = layer.shape === 'rect' ? layer.style?.radius ?? 0 : 0;
        const radii = layer.shape === 'rect' ? layer.style?.radii : undefined;
        const cornerRadius: number | [number, number, number, number] = radii
            ? [
                  radii.topLeft ?? baseRadius,
                  radii.topRight ?? baseRadius,
                  radii.bottomRight ?? baseRadius,
                  radii.bottomLeft ?? baseRadius,
              ]
            : baseRadius;
        const strokePos = stroke?.position ?? 'center';
        const sw = stroke?.width ?? 0;

        if (layer.shape === 'rect' && stroke && sw > 0 && strokePos !== 'center') {
            const inset = strokePos === 'inside' ? sw / 2 : -sw / 2;
            const shift = (v: number) =>
                Math.max(0, strokePos === 'inside' ? v - sw / 2 : v + sw / 2);
            const strokeCorner: number | number[] = Array.isArray(cornerRadius)
                ? cornerRadius.map(shift)
                : shift(cornerRadius);
            return (
                <Group
                    ref={setPrimaryShapeRef as React.Ref<Konva.Group>}
                    {...commonShape}
                    width={rect.width}
                    height={rect.height}
                >
                    {withShadows(shadows, (shadowProps, _isPrimary, key) => (
                        <Rect
                            key={key ?? undefined}
                            x={0}
                            y={0}
                            width={rect.width}
                            height={rect.height}
                            cornerRadius={cornerRadius}
                            {...fill}
                            {...shadowProps}
                        />
                    ))}
                    {/* Stroke-only rect: explicit fillEnabled=false because
                        react-konva can leave a stale `fill` attr on the Konva
                        node when the prop is simply omitted during reconciliation. */}
                    <Rect
                        x={inset}
                        y={inset}
                        width={rect.width - inset * 2}
                        height={rect.height - inset * 2}
                        cornerRadius={strokeCorner}
                        fillEnabled={false}
                        {...colorToKonvaStroke(
                            strokeColorAsColor(stroke.color),
                            rect.width - inset * 2,
                            rect.height - inset * 2,
                        )}
                        strokeWidth={sw}
                        lineJoin={stroke?.lineJoin}
                        {...strokeDash}
                        listening={false}
                    />
                </Group>
            );
        }

        return withShadows(shadows, (shadowProps, isPrimary, key) => (
            <Rect
                key={key ?? undefined}
                ref={isPrimary ? (setPrimaryShapeRef as React.Ref<Konva.Rect>) : undefined}
                {...commonShape}
                listening={isPrimary ? commonShape.listening : false}
                width={rect.width}
                height={rect.height}
                cornerRadius={cornerRadius}
                {...fill}
                {...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height)}
                strokeWidth={stroke?.width}
                lineJoin={stroke?.lineJoin}
                {...strokeDash}
                {...shadowProps}
            />
        ));
    }

    if (layer.type === 'text') {
        const raw = resolveLocalizedText(layer.content, activeLocale);
        const text = applyTextTransform(raw, layer.textTransform);
        const align = resolveTextAlign(layer.align, localeDirection);
        const fontStyle = composeFontStyle(layer.fontWeight, layer.fontStyle);
        const shadows = resolveShadows(layer.style);
        const stroke = layer.style?.stroke;
        const isAuto = layer.fitMode === 'auto';
        // Cap the auto-mode box at canvas width so long text wraps instead of
        // running off-screen. `wrapped` flips wrap mode to 'word' below so
        // Konva matches the wrapped measurement; height grows to fit.
        const natural = isAuto
            ? measureTextNaturalSize(layer, activeLocale, canvas.width)
            : null;
        const boxW = natural ? natural.width : rect.width;
        const boxH = natural ? natural.height : rect.height;
        // Auto-size text keeps its anchor "reference point" stable: a
        // center-anchored layer recenters around that point, an end-anchored
        // one keeps its right/bottom edge pinned, a start-anchored one keeps
        // its top-left. Without this the box drifts as glyphs grow/shrink.
        const leftAlign =
            layer.anchors.left && layer.anchors.left.type !== 'auto'
                ? layer.anchors.left.align
                : undefined;
        const topAlign =
            layer.anchors.top && layer.anchors.top.type !== 'auto'
                ? layer.anchors.top.align
                : undefined;
        const drawX = isAuto
            ? rect.x +
              (leftAlign === 'center'
                  ? (rect.width - boxW) / 2
                  : leftAlign === 'end'
                    ? rect.width - boxW
                    : 0)
            : rect.x;
        const drawY = isAuto
            ? rect.y +
              (topAlign === 'center'
                  ? (rect.height - boxH) / 2
                  : topAlign === 'end'
                    ? rect.height - boxH
                    : 0)
            : rect.y;
        const textFill = layer.fill
            ? colorToKonvaFill(layer.fill, boxW, boxH)
            : { fill: layer.color };
        const effectiveFontSize = fitTextSize(layer, boxW, boxH, activeLocale);
        const wrapMode =
            // Auto-mode that hit the canvas wrap cap behaves like wrap mode.
            natural?.wrapped
                ? 'word'
                : isAuto || layer.fitMode === 'clip'
                  ? 'none'
                  : 'word';
        return withShadows(shadows, (shadowProps, isPrimary, key) => (
            <Text
                key={key ?? undefined}
                ref={isPrimary ? (setTextRef as React.Ref<Konva.Text>) : undefined}
                {...commonShape}
                listening={isPrimary ? commonShape.listening : false}
                x={drawX}
                y={drawY}
                width={boxW}
                height={boxH}
                text={text}
                fontFamily={localeAwareFontStack(layer.fontFamily, activeLocale)}
                fontSize={effectiveFontSize}
                fontStyle={fontStyle}
                lineHeight={safeLineHeight(text, layer.lineHeight)}
                letterSpacing={safeLetterSpacing(text, layer.letterSpacing)}
                align={align}
                verticalAlign="top"
                {...textFill}
                wrap={wrapMode}
                ellipsis={layer.fitMode === 'clip'}
                {...colorToKonvaStroke(strokeColorAsColor(stroke?.color), boxW, boxH)}
                strokeWidth={stroke?.width}
                // Paint fill over stroke so heavy outlines don't bleed inward
                // and swallow the glyph — mirrors CSS text-stroke + fill order.
                fillAfterStrokeEnabled={stroke?.width ? true : undefined}
                {...shadowProps}
            />
        ));
    }

    if (layer.type === 'image') {
        return (
            <RasterLayerNode
                layer={layer}
                image={image}
                rect={rect}
                commonShape={commonShape}
                isBoundToClipShape={isBoundToClipShape}
                clipShapeBinding={clipShapeBinding}
                forwardedRef={ref}
            />
        );
    }

    // Groups are non-visual — children stay flat in the array and render as
    // siblings. Skip rendering so we don't show an invisible hit target.
    if (layer.type === 'group') return null;

    if (layer.type === 'callout') {
        return (
            <CalloutNode
                layer={layer}
                canvas={canvas}
                rect={rect}
                siblings={siblings ?? []}
                commonShape={commonShape}
                forwardedRef={ref as React.Ref<Konva.Group>}
            />
        );
    }

    return (
        <FrameNode
            layer={layer}
            rect={rect}
            devicePreset={devicePreset}
            commonShape={commonShape}
            screenImage={image}
            forwardedRef={ref}
            onRequestFrameScreenUpload={onRequestFrameScreenUpload}
        />
    );
});

// Memoized so a parent CanvasStage re-render (pan → culling `setState`, zoom →
// `setScale`) doesn't cascade into re-running every layer's body. Inactive
// screens pass `undefined` for all callbacks + stable-valued props, so their
// ~144 nodes skip re-render entirely on scroll. Real edits still flow through
// because LayerNode subscribes to the Zustand store internally (activeLocale,
// crop/pen mode, …) — memo only blocks the wasteful prop-driven cascade, not
// store updates. The active screen's nodes still re-render on edit (their
// `layer` ref changes via Immer), which is the desired, cheap behavior.
export const LayerNode = memo(LayerNodeInner);

type Rect = { x: number; y: number; width: number; height: number };

type CommonShapeProps = Konva.NodeConfig & {
    onMouseDown: (e: Konva.KonvaEventObject<MouseEvent>) => void;
    onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
    onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
    onTransform: (e: Konva.KonvaEventObject<Event>) => void;
    onTransformEnd: (e: Konva.KonvaEventObject<Event>) => void;
};

/**
 * Render a callout layer (zoom-callout). Sources its pixels from another
 * layer (`sourceLayerId` — must be a frame or image; recursive callouts
 * rejected at authoring time). The source's region defined by `sourceRect`
 * (percent of source's resolved rect) gets scaled up to fill `displayRect`
 * (= the callout's own anchors-resolved rect) and clipped to `shape`.
 *
 * Phase 1: ellipse / rounded-rect / pill clip + nested LayerNode render.
 * No leader line yet (Phase 4). Orphan source → dashed-outline placeholder.
 */
function CalloutNode({
    layer,
    canvas,
    rect: displayRect,
    siblings,
    commonShape,
    forwardedRef,
}: {
    layer: CalloutLayer;
    canvas: Canvas;
    rect: Rect;
    siblings: Layer[];
    commonShape: CommonShapeProps;
    forwardedRef: React.Ref<Konva.Group>;
}) {
    const source = siblings.find((l) => l.id === layer.sourceLayerId);
    const isValidSource =
        source &&
        source.type !== 'callout' &&
        source.type !== 'group' &&
        source.type !== 'background';

    // Orphan / wrong-type source — render a dashed placeholder so the user
    // sees something selectable. Phase 4 adds the "Source missing" label.
    if (!source || !isValidSource) {
        return (
            <Group
                {...commonShape}
                ref={forwardedRef}
                width={displayRect.width}
                height={displayRect.height}
            >
                <KonvaShape
                    width={displayRect.width}
                    height={displayRect.height}
                    listening={false}
                    sceneFunc={(ctx, shape) => {
                        const c = ctx as unknown as CanvasRenderingContext2D;
                        clipCalloutShape(c, layer, displayRect.width, displayRect.height);
                        ctx.setAttr('strokeStyle', 'rgba(0,0,0,0.3)');
                        ctx.setAttr('lineWidth', 1);
                        ctx.setAttr('lineDash', [4, 4]);
                        ctx.stroke();
                        ctx.fillStrokeShape(shape);
                    }}
                />
                {/* Hit-target Rect — keeps orphan callouts clickable so the
                    user can select + re-pick from the properties panel. */}
                <Rect
                    x={0}
                    y={0}
                    width={displayRect.width}
                    height={displayRect.height}
                    fill="rgba(0,0,0,0)"
                />
            </Group>
        );
    }

    const sourceFull = resolveAnchors(source.anchors, canvas);
    const sourcePxX = sourceFull.x + (layer.sourceRect.x / 100) * sourceFull.width;
    const sourcePxY = sourceFull.y + (layer.sourceRect.y / 100) * sourceFull.height;
    const sourcePxW = Math.max(1, (layer.sourceRect.width / 100) * sourceFull.width);
    const zoom = displayRect.width / sourcePxW;

    // Leader line endpoints in canvas coords. Manual `from`/`to` win when
    // present; otherwise auto-route from source perimeter to callout perimeter.
    const leader = layer.leader;
    let leaderLocal: { from: Point; to: Point } | null = null;
    if (leader?.enabled) {
        const routed =
            leader.from && leader.to
                ? { from: leader.from, to: leader.to }
                : autoRouteLeader(sourceFull, displayRect, layer.shape);
        leaderLocal = {
            from: { x: routed.from.x - displayRect.x, y: routed.from.y - displayRect.y },
            to: { x: routed.to.x - displayRect.x, y: routed.to.y - displayRect.y },
        };
    }
    const leaderStrokeColor = leader ? strokeColorAsColor(leader.stroke) : undefined;
    const leaderStrokeSolid =
        leaderStrokeColor?.type === 'solid'
            ? leaderStrokeColor.value
            : (leaderStrokeColor?.stops?.[0]?.color ?? '#0F172A');
    const leaderWidth = leader?.width ?? 2;
    const leaderHead = Math.max(4, leaderWidth * 3);
    const leaderHeadW = Math.max(4, leaderWidth * 2.5);

    const calloutShadows = resolveShadows(layer.style);
    // Konva.Container.getClientRect walks every child's bbox (it ignores
    // clipFunc), so the default returns a giant union including the
    // magnified source content extending beyond the visible callout. Konva
    // .Transformer uses getClientRect to size the selection — without this
    // override the handles wrap that giant union.
    //
    // The fix delegates to the hit-target Rect, which is a plain Konva.Rect
    // at (0, 0) sized displayRect.width × displayRect.height inside this
    // Group. Its getClientRect applies the full transform chain (this
    // Group's x/y + parent screen transform + stage transform) and returns
    // the tight bbox we want.
    const setOuterRef = (node: Konva.Group | null) => {
        if (typeof forwardedRef === 'function') forwardedRef(node);
        else if (forwardedRef && 'current' in forwardedRef) {
            (forwardedRef as React.MutableRefObject<Konva.Group | null>).current = node;
        }
        if (!node) return;
        const findHitTarget = () =>
            (node.children ?? []).find(
                (c) => c.attrs?.name === 'callout-bbox-target',
            );
        const defaultGetClientRect = Konva.Container.prototype.getClientRect.bind(node);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (node as any).getClientRect = (cfg?: {
            skipShadow?: boolean;
            skipStroke?: boolean;
            relativeTo?: Konva.Container;
        }) => {
            const hit = findHitTarget();
            if (hit) return hit.getClientRect(cfg);
            return defaultGetClientRect(cfg);
        };
    };
    return (
        <Group
            {...commonShape}
            ref={setOuterRef}
            width={displayRect.width}
            height={displayRect.height}
        >
            {/* Shadow casters — drawn BEFORE the clipped content so the
                shadows project from the callout silhouette regardless of
                whether a stroke is present. Each shape is filled with black
                so Konva's shadow has pixels to project from; the actual
                content paints on top and hides the black fill. Stacked via
                `withShadows` so multi-shadow layouts (Apple-style 2-3 layered
                drop shadows) all render — previously only the last shadow
                in the list was drawn. */}
            {withShadows(calloutShadows, (shadowProps, _isPrimary, key) => (
                <KonvaShape
                    key={key ?? 'primary'}
                    listening={false}
                    fill="#000"
                    sceneFunc={(ctx, shape) => {
                        const c = ctx as unknown as CanvasRenderingContext2D;
                        clipCalloutShape(c, layer, displayRect.width, displayRect.height);
                        ctx.fillStrokeShape(shape);
                    }}
                    {...shadowProps}
                />
            ))}
            {/* Leader renders next so the callout body paints on top of any
                stroke that grazes the perimeter. Endpoints are in local coords
                (canvas point − displayRect origin) because the outer Group is
                positioned at displayRect.x/y. */}
            {leader?.enabled && leaderLocal && leader.style === 'line' && (
                <Line
                    points={[leaderLocal.from.x, leaderLocal.from.y, leaderLocal.to.x, leaderLocal.to.y]}
                    {...colorToKonvaStroke(leaderStrokeColor, displayRect.width, displayRect.height)}
                    strokeWidth={leaderWidth}
                    listening={false}
                />
            )}
            {leader?.enabled && leaderLocal && leader.style === 'arrow' && (
                <Arrow
                    points={[leaderLocal.from.x, leaderLocal.from.y, leaderLocal.to.x, leaderLocal.to.y]}
                    {...colorToKonvaStroke(leaderStrokeColor, displayRect.width, displayRect.height)}
                    strokeWidth={leaderWidth}
                    fill={leaderStrokeSolid}
                    pointerLength={leaderHead}
                    pointerWidth={leaderHeadW}
                    listening={false}
                />
            )}
            {leader?.enabled && leaderLocal && leader.style === 'curve' && (
                <KonvaShape
                    {...colorToKonvaStroke(leaderStrokeColor, displayRect.width, displayRect.height)}
                    strokeWidth={leaderWidth}
                    listening={false}
                    sceneFunc={(ctx, shape) => {
                        const from = leaderLocal!.from;
                        const to = leaderLocal!.to;
                        const dx = to.x - from.x;
                        const dy = to.y - from.y;
                        const len = Math.hypot(dx, dy) || 1;
                        const perpLen = len * 0.2;
                        const perpX = (-dy / len) * perpLen;
                        const perpY = (dx / len) * perpLen;
                        const cx = (from.x + to.x) / 2;
                        const cy = (from.y + to.y) / 2;
                        ctx.beginPath();
                        ctx.moveTo(from.x, from.y);
                        ctx.quadraticCurveTo(cx + perpX, cy + perpY, to.x, to.y);
                        ctx.strokeShape(shape);
                    }}
                />
            )}
            {/* Outer Group at displayRect (x/y from commonShape). Inner Group
                clips to the callout shape; innermost Group scales + offsets
                the source layer so its sourceRect lands at the local origin. */}
            <Group
                width={displayRect.width}
                height={displayRect.height}
                listening={false}
                clipFunc={(ctx) => {
                    const c = ctx as unknown as CanvasRenderingContext2D;
                    clipCalloutShape(c, layer, displayRect.width, displayRect.height);
                }}
            >
                <Group
                    x={-sourcePxX * zoom}
                    y={-sourcePxY * zoom}
                    scaleX={zoom}
                    scaleY={zoom}
                    listening={false}
                >
                    {/* Recursive LayerNode for the source. siblings list keeps
                        flowing so nested clip-shape/source resolution stays
                        consistent. The source renders at its native canvas
                        position; the outer scale + offset map that into the
                        callout's display rect. */}
                    <LayerNode
                        layer={source}
                        canvas={canvas}
                        siblings={siblings}
                        draggable={false}
                    />
                </Group>
            </Group>
            {/* Shape outline (stroke only — shadow lives on the separate
                caster above so it projects from the silhouette even when no
                stroke is set). The same clipCalloutShape path used to clip
                the content is used to outline it. */}
            {(() => {
                const strokeStyle = layer.style?.stroke;
                const strokeColor = strokeColorAsColor(strokeStyle?.color);
                const hasStroke = (strokeStyle?.width ?? 0) > 0 && strokeColor;
                if (!hasStroke) return null;
                return (
                    <KonvaShape
                        listening={false}
                        sceneFunc={(ctx, shape) => {
                            const c = ctx as unknown as CanvasRenderingContext2D;
                            clipCalloutShape(c, layer, displayRect.width, displayRect.height);
                            ctx.fillStrokeShape(shape);
                        }}
                        {...colorToKonvaStroke(
                            strokeColor,
                            displayRect.width,
                            displayRect.height,
                        )}
                        strokeWidth={strokeStyle!.width}
                        {...dashConfig(
                            strokeStyle!.dash,
                            strokeStyle!.width ?? 0,
                            strokeStyle!.dashOffset,
                            strokeStyle!.dashGap,
                        )}
                    />
                );
            })()}
            {/* Hit target — Konva Groups without a listening child don't
                generate hit pixels, so without this transparent Rect the
                callout can't be clicked to select. Renders after the clip
                group so the callout's content paints first; this sits on
                top with `fill rgba(0,0,0,0)` to catch mouse events without
                visually changing anything. The `name` is used by the outer
                Group's overridden `getClientRect` to delegate the selection
                bbox to this Rect (which is sized to displayRect cleanly,
                vs. the default child-union that grabs the magnified content
                extending beyond the clipFunc). */}
            <Rect
                name="callout-bbox-target"
                x={0}
                y={0}
                width={displayRect.width}
                height={displayRect.height}
                fill="rgba(0,0,0,0)"
            />
        </Group>
    );
}

/**
 * Build the callout's clip path in local coords (0,0) to (w,h). Mirrored
 * by `renderScreen.ts` for the export pipeline — keep them byte-equivalent.
 * `pill` and `rounded-rect` both delegate to the existing `roundedRectPath`
 * helper (defined later in this file); `pill` picks the radius that turns
 * a rect into a stadium shape.
 */
export function clipCalloutShape(
    ctx: CanvasRenderingContext2D,
    layer: CalloutLayer,
    w: number,
    h: number,
): void {
    if (layer.shape === 'ellipse') {
        // True circle (not oval) regardless of bbox aspect — diameter
        // equals the bbox's smaller side so a 200×100 callout draws a
        // 100-px circle centered horizontally instead of an oblong
        // pill-shape. The "Ellipse" shape value is kept for back-compat
        // with persisted layouts (slow kit etc.) but the picker label
        // is "Circle" — users expect circle behavior, not oval.
        const r = Math.min(w, h) / 2;
        ctx.beginPath();
        ctx.ellipse(w / 2, h / 2, r, r, 0, 0, Math.PI * 2);
        ctx.closePath();
        return;
    }
    const r =
        layer.shape === 'pill'
            ? Math.min(w, h) / 2
            : Math.max(0, layer.cornerRadius ?? 8);
    roundedRectPath(ctx, 0, 0, w, h, r);
}

type Point = { x: number; y: number };
type RectGeo = { x: number; y: number; width: number; height: number };

/**
 * Walk a ray from `centerA` toward `centerB` and return the point where it
 * exits the rect inscribed at `rect`. Used as the source-side anchor for an
 * auto-routed leader line (frames + images are always rect-aligned).
 */
function intersectRectEdge(centerA: Point, centerB: Point, rect: RectGeo): Point {
    const dx = centerB.x - centerA.x;
    const dy = centerB.y - centerA.y;
    const len = Math.hypot(dx, dy);
    if (len < 1e-6) return { x: centerA.x, y: centerA.y };
    const ux = dx / len;
    const uy = dy / len;
    const halfW = rect.width / 2;
    const halfH = rect.height / 2;
    const tx = Math.abs(ux) > 1e-6 ? halfW / Math.abs(ux) : Infinity;
    const ty = Math.abs(uy) > 1e-6 ? halfH / Math.abs(uy) : Infinity;
    const t = Math.min(tx, ty);
    return { x: centerA.x + ux * t, y: centerA.y + uy * t };
}

/**
 * Walk a ray from `centerA` toward `centerB` and return the point where it
 * exits the ellipse inscribed in `rect`. Parametric form keeps the endpoint
 * tangent to the visible callout perimeter for `shape === 'ellipse'`.
 */
function intersectEllipseEdge(centerA: Point, centerB: Point, rect: RectGeo): Point {
    // The "Ellipse" shape now renders as a true circle (see clipCalloutShape);
    // leader endpoints must aim at the circle's perimeter, not the oval's, or
    // the line tip floats inside / outside the visible boundary when the bbox
    // is non-square. Radius = min(w, h)/2; circle is centered in the bbox.
    const cx = rect.x + rect.width / 2;
    const cy = rect.y + rect.height / 2;
    const r = Math.min(rect.width, rect.height) / 2;
    const dx = centerB.x - centerA.x;
    const dy = centerB.y - centerA.y;
    const len = Math.hypot(dx, dy);
    if (len < 1e-6 || r < 1e-6) {
        return { x: cx, y: cy };
    }
    return {
        x: cx + (r * dx) / len,
        y: cy + (r * dy) / len,
    };
}

/**
 * Compute the auto-routed leader endpoints connecting the source rect's
 * perimeter to the callout's perimeter. Returns `{ from, to }` in canvas
 * coordinates: `from` is on the source's rect edge, `to` is on the callout's
 * edge (rect for rounded-rect/pill, ellipse for ellipse). Mirrored by
 * `renderScreen.ts` — keep byte-equivalent.
 */
export function autoRouteLeader(
    sourceRectPx: RectGeo,
    displayRect: RectGeo,
    calloutShape: 'ellipse' | 'rounded-rect' | 'pill',
): { from: Point; to: Point } {
    const sourceCenter = {
        x: sourceRectPx.x + sourceRectPx.width / 2,
        y: sourceRectPx.y + sourceRectPx.height / 2,
    };
    const displayCenter = {
        x: displayRect.x + displayRect.width / 2,
        y: displayRect.y + displayRect.height / 2,
    };
    const from = intersectRectEdge(sourceCenter, displayCenter, sourceRectPx);
    const to =
        calloutShape === 'ellipse'
            ? intersectEllipseEdge(displayCenter, sourceCenter, displayRect)
            : intersectRectEdge(displayCenter, sourceCenter, displayRect);
    return { from, to };
}

/**
 * Build Konva filter list + attrs from an ImageAdjust block. Hue & saturation
 * share the HSL filter — we add it once when either is non-default so we don't
 * run it redundantly. Undefined / zero values are skipped so inactive filters
 * never hit the cache pipeline.
 */
function resolveImageAdjust(adjust: import('@/types/layout').ImageAdjust | undefined): {
    filters: KonvaFilter[];
    attrs: Record<string, number>;
} {
    const filters: KonvaFilter[] = [];
    const attrs: Record<string, number> = {};
    if (!adjust) return { filters, attrs };
    if (adjust.brightness && adjust.brightness !== 0) {
        filters.push(Konva.Filters.Brighten);
        attrs.brightness = adjust.brightness;
    }
    if (adjust.contrast && adjust.contrast !== 0) {
        filters.push(Konva.Filters.Contrast);
        attrs.contrast = adjust.contrast;
    }
    const hue = adjust.hue ?? 0;
    const saturation = adjust.saturation ?? 0;
    if (hue !== 0 || saturation !== 0) {
        filters.push(Konva.Filters.HSL);
        attrs.hue = hue;
        attrs.saturation = saturation;
    }
    if (adjust.blur && adjust.blur > 0) {
        filters.push(Konva.Filters.Blur);
        attrs.blurRadius = adjust.blur;
    }
    if (adjust.grayscale) filters.push(Konva.Filters.Grayscale);
    if (adjust.invert) filters.push(Konva.Filters.Invert);
    if (adjust.tint && adjust.tint.amount > 0) {
        const { r, g, b } = parseHexColor(adjust.tint.color);
        filters.push(Konva.Filters.RGBA);
        attrs.red = r;
        attrs.green = g;
        attrs.blue = b;
        attrs.alpha = Math.min(1, Math.max(0, adjust.tint.amount));
    }
    return { filters, attrs };
}

function parseHexColor(hex: string): { r: number; g: number; b: number } {
    const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex ?? '').trim());
    if (!m) return { r: 0, g: 0, b: 0 };
    const h = m[1];
    const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
    const n = parseInt(full, 16);
    return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
}

type RasterLayerNodeProps = {
    layer: import('@/types/layout').ImageLayer;
    image: HTMLImageElement | undefined;
    rect: Rect;
    commonShape: CommonShapeProps;
    isBoundToClipShape: boolean;
    clipShapeBinding: ClipShapeBinding | undefined;
    forwardedRef: React.Ref<Konva.Node>;
};

// Build a rounded-clipped image silhouette on an offscreen canvas — the
// reference shape for an image-layer drop shadow. Drawing this offscreen with
// shadow attrs respects BOTH the image's alpha (transparent PNG silhouette)
// AND the rounded corners (round-rect clip), so the shadow halo follows the
// visible image's outline. Returns null if the layer rect is degenerate or
// the canvas can't be created.
function buildImageSilhouetteOffscreen(input: {
    image: HTMLImageElement;
    rectWidth: number;
    rectHeight: number;
    drawX: number;
    drawY: number;
    drawW: number;
    drawH: number;
    flipScaleX: number;
    flipScaleY: number;
    flipOffsetX: number;
    flipOffsetY: number;
    cropAttr: { x: number; y: number; width: number; height: number } | undefined;
    imgRadius: number;
}): HTMLCanvasElement | null {
    const w = Math.max(1, Math.ceil(input.rectWidth));
    const h = Math.max(1, Math.ceil(input.rectHeight));
    if (w <= 1 && h <= 1) return null;
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;
    ctx.save();
    if (input.imgRadius > 0) {
        roundedRectPath(ctx, 0, 0, input.rectWidth, input.rectHeight, input.imgRadius);
        ctx.clip();
    }
    ctx.translate(input.drawX + input.flipOffsetX, input.drawY + input.flipOffsetY);
    ctx.scale(input.flipScaleX, input.flipScaleY);
    if (input.cropAttr) {
        ctx.drawImage(
            input.image,
            input.cropAttr.x,
            input.cropAttr.y,
            input.cropAttr.width,
            input.cropAttr.height,
            0,
            0,
            input.drawW,
            input.drawH,
        );
    } else {
        ctx.drawImage(input.image, 0, 0, input.drawW, input.drawH);
    }
    ctx.restore();
    return canvas;
}

function RasterLayerNode({
    layer,
    image,
    rect,
    commonShape,
    isBoundToClipShape,
    clipShapeBinding,
    forwardedRef,
}: RasterLayerNodeProps) {
    const imageNodeRef = useRef<Konva.Image>(null);
    const { filters, attrs } = resolveImageAdjust(layer.adjust);
    const activeFilterKey = filters
        .map((f) => (typeof f === 'function' ? f.name : String(f)))
        .join('|');

    // While this layer is in crop mode, render the full image (no crop) so
    // the user sees the entire source under the dark mask + crop rect. Without
    // this, every drag commit re-applies crop and stretches the visible
    // content, making it look like the image "moves" inside the crop rect.
    // The crop values still persist on layer.crop — they take visual effect
    // again as soon as crop mode exits.
    const cropModeLayerIdForLayer = useEditor((s) => s.cropModeLayerId);
    const isThisLayerCropping = cropModeLayerIdForLayer === layer.id;
    const cropAttr = useMemo(() => {
        if (!image || !layer.crop || isThisLayerCropping) return undefined;
        const top = Math.max(0, Math.min(0.99, layer.crop.top ?? 0));
        const right = Math.max(0, Math.min(0.99, layer.crop.right ?? 0));
        const bottom = Math.max(0, Math.min(0.99, layer.crop.bottom ?? 0));
        const left = Math.max(0, Math.min(0.99, layer.crop.left ?? 0));
        const wFrac = Math.max(0.001, 1 - left - right);
        const hFrac = Math.max(0.001, 1 - top - bottom);
        if (top === 0 && right === 0 && bottom === 0 && left === 0) return undefined;
        return {
            x: left * image.width,
            y: top * image.height,
            width: wFrac * image.width,
            height: hFrac * image.height,
        };
    }, [image, layer.crop, isThisLayerCropping]);

    // Konva requires cache() before filters run. Re-cache whenever the image,
    // rendered size, the active filter set, or the crop rect flips — value-only
    // tweaks (slider drags within the same filter) don't need a re-cache, the
    // attrs flow live.
    useEffect(() => {
        const node = imageNodeRef.current;
        if (!node) return;
        if (filters.length > 0 && image) {
            node.cache();
        } else {
            node.clearCache();
        }
    }, [
        image,
        rect.width,
        rect.height,
        activeFilterKey,
        filters.length,
        cropAttr?.x,
        cropAttr?.y,
        cropAttr?.width,
        cropAttr?.height,
    ]);

    if (!image) {
        return (
            <Rect
                ref={forwardedRef as React.Ref<Konva.Rect>}
                {...commonShape}
                width={rect.width}
                height={rect.height}
                fill="#E4E4E7"
                stroke="#A1A1AA"
                strokeWidth={2}
                dash={[12, 8]}
            />
        );
    }

    const fit = layer.fit;
    const zoom = Math.max(0.01, layer.zoom ?? 1);
    const offX = layer.offsetX ?? 0;
    const offY = layer.offsetY ?? 0;
    const flipX = layer.flipX === true;
    const flipY = layer.flipY === true;
    // Konva's skewX/skewY are slope factors, not angles. Users think in degrees,
    // so the schema stores degrees (−45..45) and we tan-convert at render.
    const skewX = Math.tan(((layer.skewX ?? 0) * Math.PI) / 180);
    const skewY = Math.tan(((layer.skewY ?? 0) * Math.PI) / 180);
    // Strip-based trapezoid perspective. Active only when the user has
    // explicitly set a tilt — avoids paying the sceneFunc cost for the common
    // flat case. Filters are skipped while tilted (documented limitation).
    const tiltY = layer.tiltY ?? 0;
    const tiltX = layer.tiltX ?? 0;
    const hasTilt = Math.abs(tiltY) > 0.01 || Math.abs(tiltX) > 0.01;

    // Effective source dimensions reflect any active crop, so fitBox scales the
    // visible region (not the original image) to match cover/contain behaviour.
    const srcW = cropAttr?.width ?? image.width;
    const srcH = cropAttr?.height ?? image.height;

    // Geometry: fill = stretch to layer rect, cover/contain = fitBox with
    // pan/zoom. Pan/zoom can push content beyond the rect, so both cover and
    // contain rely on the outer clip to stay inside the layer bounds.
    let drawX = 0;
    let drawY = 0;
    let drawW = rect.width;
    let drawH = rect.height;
    if (fit === 'cover' || fit === 'contain') {
        const box = fitBoxDims(srcW, srcH, rect.width, rect.height, fit, zoom, offX, offY);
        drawX = box.x;
        drawY = box.y;
        drawW = box.width;
        drawH = box.height;
    }

    // Flip via negative scale around the drawn rect's center: when scaleX is
    // -1, shift the origin by drawW so the image stays in the same screen box.
    const flipScaleX = flipX ? -1 : 1;
    const flipScaleY = flipY ? -1 : 1;
    const flipOffsetX = flipX ? drawW : 0;
    const flipOffsetY = flipY ? drawH : 0;

    if (isBoundToClipShape && clipShapeBinding) {
        // Pattern B: shape is a pure mask. Image keeps its own rect + rotation
        // + selection + drag. The clipFunc draws the shape's path in canvas-
        // absolute coords, then transforms that into the clip group's local
        // space — so moving the image reveals different regions of the mask.
        //
        // Structure: outer group owns commonShape (position, rotation, blend
        // mode, listening); inner group owns the clipFunc. Splitting keeps
        // globalCompositeOperation working — Konva doesn't apply blend modes
        // correctly when clipFunc and GCO sit on the same Group.
        const imgRotRad = ((layer.rotation ?? 0) * Math.PI) / 180;
        const shapeRotRad = (clipShapeBinding.rotation * Math.PI) / 180;
        const shapeRect = clipShapeBinding.rect;
        return (
            <Group
                {...commonShape}
                width={rect.width}
                height={rect.height}
                skewX={skewX}
                skewY={skewY}
            >
                <Group
                    width={rect.width}
                    height={rect.height}
                    listening={false}
                    clipFunc={(ctx) => {
                        const c = ctx as unknown as CanvasRenderingContext2D;
                        c.save();
                        if (imgRotRad) c.rotate(-imgRotRad);
                        c.translate(-rect.x, -rect.y);
                        c.translate(shapeRect.x, shapeRect.y);
                        if (shapeRotRad) c.rotate(shapeRotRad);
                        buildShapePath(c, clipShapeBinding.shape, shapeRect.width, shapeRect.height);
                        c.restore();
                    }}
                >
                    {hasTilt ? (
                        <KonvaShape
                            x={drawX + flipOffsetX}
                            y={drawY + flipOffsetY}
                            width={drawW}
                            height={drawH}
                            scaleX={flipScaleX}
                            scaleY={flipScaleY}
                            listening={false}
                            sceneFunc={(ctx, shape) => {
                                drawTilt(
                                    ctx as unknown as CanvasRenderingContext2D,
                                    image,
                                    shape.width(),
                                    shape.height(),
                                    tiltX,
                                    tiltY,
                                    cropAttr,
                                );
                            }}
                        />
                    ) : (
                        <KonvaImage
                            ref={imageNodeRef}
                            image={image}
                            x={drawX + flipOffsetX}
                            y={drawY + flipOffsetY}
                            width={drawW}
                            height={drawH}
                            scaleX={flipScaleX}
                            scaleY={flipScaleY}
                            crop={cropAttr}
                            filters={filters.length > 0 ? filters : undefined}
                            {...attrs}
                            listening={false}
                        />
                    )}
                    {layer.overlay && layer.overlay.opacity > 0 && (
                        <Rect
                            x={0}
                            y={0}
                            width={rect.width}
                            height={rect.height}
                            fill={layer.overlay.color}
                            opacity={layer.overlay.opacity}
                            globalCompositeOperation="source-atop"
                            listening={false}
                        />
                    )}
                </Group>
                {/* Transparent hit target so the image stays clickable across
                    its full rect — matches the non-clipped needsGroup branch. */}
                <Rect x={0} y={0} width={rect.width} height={rect.height} fill="rgba(0,0,0,0)" />
            </Group>
        );
    }

    const imgStroke = layer.style?.stroke;
    const imgShadows = resolveShadows(layer.style);
    const imgRadius = Math.max(0, layer.style?.radius ?? 0);
    const imgStrokeW = imgStroke?.width ?? 0;
    const imgStrokeDash = dashConfig(imgStroke?.dash, imgStrokeW, imgStroke?.dashOffset, imgStroke?.dashGap);
    const hasStroke = imgStroke != null && imgStrokeW > 0;
    const hasShadow = imgShadows.length > 0;
    const hasOverlay = layer.overlay != null && layer.overlay.opacity > 0;

    // Any pan/zoom can push contain-fit content outside its letterbox, so clip
    // to the layer rect whenever we're not in `fill` mode with no rounding.
    // `fill` edge-to-edge still needs the clip when a corner radius is set.
    const hasPanZoom = zoom !== 1 || offX !== 0 || offY !== 0;
    // Tilt shrinks the image below its rect on at least one axis, so it can
    // extend slightly past the bounds at steep angles — force a clip when
    // tilted so the shrunken edges never bleed into neighbouring layers.
    const needsClip =
        imgRadius > 0 || hasPanZoom || fit === 'contain' || flipX || flipY || hasTilt;
    const needsGroup = needsClip || hasStroke || hasShadow || hasOverlay;

    if (!needsGroup) {
        return (
            <KonvaImage
                ref={(node) => {
                    imageNodeRef.current = node;
                    if (typeof forwardedRef === 'function') forwardedRef(node);
                    else if (forwardedRef)
                        (forwardedRef as React.MutableRefObject<Konva.Node | null>).current = node;
                }}
                {...commonShape}
                image={image}
                width={rect.width}
                height={rect.height}
                skewX={skewX}
                skewY={skewY}
                crop={cropAttr}
                filters={filters.length > 0 ? filters : undefined}
                {...attrs}
            />
        );
    }

    return (
        <Group
            ref={forwardedRef as React.Ref<Konva.Group>}
            {...commonShape}
            width={rect.width}
            height={rect.height}
            skewX={skewX}
            skewY={skewY}
        >
            {hasShadow && hasTilt &&
                withShadows(imgShadows, (sp, _isPrimary, key) => (
                    <KonvaShape
                        key={key ?? undefined}
                        width={rect.width}
                        height={rect.height}
                        sceneFunc={(ctx, node) => {
                            buildTrapezoidPath(
                                ctx as unknown as CanvasRenderingContext2D,
                                0,
                                0,
                                rect.width,
                                rect.height,
                                tiltX,
                                tiltY,
                                imgRadius,
                            );
                            ctx.fillStrokeShape(node);
                        }}
                        fill="#000"
                        {...sp}
                        listening={false}
                    />
                ))}
            {hasShadow && !hasTilt &&
                withShadows(imgShadows, (sp, _isPrimary, key) => (
                    // Unified shadow caster: pre-build the rounded image
                    // silhouette on an offscreen canvas, then drawImage it
                    // to the main ctx with shadow attrs applied → shadow
                    // halo follows BOTH the image's alpha AND the rounded
                    // corners. destination-out erases the silhouette pixels
                    // so only the shadow halo remains (the visible rounded
                    // image draws on top via the existing clip group).
                    <KonvaShape
                        key={key ?? undefined}
                        width={rect.width}
                        height={rect.height}
                        {...sp}
                        listening={false}
                        sceneFunc={(ctx) => {
                            const off = buildImageSilhouetteOffscreen({
                                image,
                                rectWidth: rect.width,
                                rectHeight: rect.height,
                                drawX,
                                drawY,
                                drawW,
                                drawH,
                                flipScaleX,
                                flipScaleY,
                                flipOffsetX,
                                flipOffsetY,
                                cropAttr,
                                imgRadius,
                            });
                            if (!off) return;
                            const c = ctx as unknown as CanvasRenderingContext2D;
                            // First pass: shadow ctx pre-applied by Konva,
                            // drawImage projects the halo from the silhouette.
                            c.drawImage(off, 0, 0);
                            // Second pass: disable shadow + destination-out to
                            // erase the silhouette pixels, keeping just the
                            // halo (otherwise the silhouette would obscure the
                            // rounded visible image drawn on top).
                            c.shadowColor = 'transparent';
                            c.shadowBlur = 0;
                            c.shadowOffsetX = 0;
                            c.shadowOffsetY = 0;
                            c.globalCompositeOperation = 'destination-out';
                            c.drawImage(off, 0, 0);
                        }}
                    />
                ))}
            <Group
                width={rect.width}
                height={rect.height}
                clipFunc={(ctx) => {
                    if (hasTilt) {
                        buildTrapezoidPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rect.width,
                            rect.height,
                            tiltX,
                            tiltY,
                            imgRadius,
                        );
                    } else {
                        roundedRectPath(ctx, 0, 0, rect.width, rect.height, imgRadius);
                    }
                }}
            >
                {hasTilt ? (
                    <KonvaShape
                        x={drawX + flipOffsetX}
                        y={drawY + flipOffsetY}
                        width={drawW}
                        height={drawH}
                        scaleX={flipScaleX}
                        scaleY={flipScaleY}
                        listening={false}
                        sceneFunc={(ctx, shape) => {
                            drawTilt(
                                ctx as unknown as CanvasRenderingContext2D,
                                image,
                                shape.width(),
                                shape.height(),
                                tiltX,
                                tiltY,
                                cropAttr,
                            );
                        }}
                    />
                ) : (
                    <KonvaImage
                        ref={imageNodeRef}
                        image={image}
                        x={drawX + flipOffsetX}
                        y={drawY + flipOffsetY}
                        width={drawW}
                        height={drawH}
                        scaleX={flipScaleX}
                        scaleY={flipScaleY}
                        crop={cropAttr}
                        filters={filters.length > 0 ? filters : undefined}
                        {...attrs}
                        listening={false}
                    />
                )}
                {layer.overlay && layer.overlay.opacity > 0 && (
                    <Rect
                        x={0}
                        y={0}
                        width={rect.width}
                        height={rect.height}
                        fill={layer.overlay.color}
                        opacity={layer.overlay.opacity}
                        globalCompositeOperation="source-atop"
                        listening={false}
                    />
                )}
            </Group>
            {/* Transparent hit target so clicks land across the full layer
                bounds — otherwise a letterboxed contain couldn't be selected. */}
            <Rect x={0} y={0} width={rect.width} height={rect.height} fill="rgba(0,0,0,0)" />
            {hasStroke && hasTilt && (
                <KonvaShape
                    width={rect.width}
                    height={rect.height}
                    sceneFunc={(ctx, node) => {
                        buildTrapezoidPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rect.width,
                            rect.height,
                            tiltX,
                            tiltY,
                            imgRadius,
                        );
                        ctx.fillStrokeShape(node);
                    }}
                    fillEnabled={false}
                    {...colorToKonvaStroke(strokeColorAsColor(imgStroke!.color), rect.width, rect.height)}
                    strokeWidth={imgStrokeW}
                    {...imgStrokeDash}
                    listening={false}
                />
            )}
            {hasStroke && !hasTilt && (
                <Rect
                    x={0}
                    y={0}
                    width={rect.width}
                    height={rect.height}
                    cornerRadius={imgRadius}
                    fillEnabled={false}
                    {...colorToKonvaStroke(strokeColorAsColor(imgStroke!.color), rect.width, rect.height)}
                    strokeWidth={imgStrokeW}
                    {...imgStrokeDash}
                    listening={false}
                />
            )}
        </Group>
    );
}

type FrameNodeProps = {
    layer: FrameLayer;
    rect: Rect;
    devicePreset: DevicePresetDto | undefined;
    commonShape: CommonShapeProps;
    screenImage: HTMLImageElement | undefined;
    forwardedRef: React.Ref<Konva.Node>;
    /** Only the main editor canvas passes this — AllFactorsStrip thumbnails
     *  leave it undefined so the CTA stays out of mini previews. */
    onRequestFrameScreenUpload?: (layerId: string) => void;
};

/**
 * Device frame — bezel + optional bound screen render as one layer. Frame-level
 * skew/tilt/flip cover the bezel and screen together; rotation lives on the
 * outer group (commonShape). Tilt rasterizes the frame to an offscreen canvas
 * once per geometry/style change, then sends that buffer through drawTilt — the
 * same strip-based trapezoid the image branch uses, just over a composite
 * source instead of a single bitmap.
 */
function FrameNode({
    layer,
    rect,
    devicePreset,
    commonShape,
    screenImage,
    forwardedRef,
    onRequestFrameScreenUpload,
}: FrameNodeProps) {
    const bezel = bezelGeometry(rect.width, rect.height, devicePreset);
    const innerRadius = Math.max(0, bezel.chassisRadius - bezel.inset);
    const frameStyle = layer.style;
    const chassisFill = colorToKonvaFill(
        frameStyle?.fill ?? { type: 'solid', value: '#0F172A' },
        rect.width,
        rect.height,
    );
    const chassisStrokeW = frameStyle?.stroke?.width ?? 0;
    const chassisStrokeProps = chassisStrokeW
        ? {
              ...colorToKonvaStroke(
                  strokeColorAsColor(frameStyle!.stroke!.color),
                  rect.width,
                  rect.height,
              ),
              strokeWidth: chassisStrokeW,
          }
        : null;
    const screenFillColor = layer.screenFill ?? ({ type: 'solid', value: '#F4F4F5' } as Color);
    const innerW = Math.max(0, rect.width - bezel.inset * 2);
    const innerH = Math.max(0, rect.height - bezel.inset * 2);
    const screenFill = colorToKonvaFill(screenFillColor, innerW, innerH);
    const frameShadows = resolveShadows(frameStyle);

    // Frame transform — skew + tilt + flip apply to bezel + screen together.
    const skewX = Math.tan(((layer.skewX ?? 0) * Math.PI) / 180);
    const skewY = Math.tan(((layer.skewY ?? 0) * Math.PI) / 180);
    const flipX = layer.flipX === true;
    const flipY = layer.flipY === true;
    const tiltX = layer.tiltX ?? 0;
    const tiltY = layer.tiltY ?? 0;
    const hasTilt = Math.abs(tiltX) > 0.01 || Math.abs(tiltY) > 0.01;

    // Bound screen geometry (mirrors RasterLayerNode math against the inner rect).
    const screen = layer.screen;
    const sFit = screen?.fit ?? 'cover';
    const sZoom = Math.max(0.01, screen?.zoom ?? 1);
    const sOffX = screen?.offsetX ?? 0;
    const sOffY = screen?.offsetY ?? 0;
    const screenCrop = useMemo(() => {
        if (!screenImage || !screen?.crop) return undefined;
        const top = Math.max(0, Math.min(0.99, screen.crop.top ?? 0));
        const right = Math.max(0, Math.min(0.99, screen.crop.right ?? 0));
        const bottom = Math.max(0, Math.min(0.99, screen.crop.bottom ?? 0));
        const left = Math.max(0, Math.min(0.99, screen.crop.left ?? 0));
        if (top === 0 && right === 0 && bottom === 0 && left === 0) return undefined;
        const wFrac = Math.max(0.001, 1 - left - right);
        const hFrac = Math.max(0.001, 1 - top - bottom);
        return {
            x: left * screenImage.width,
            y: top * screenImage.height,
            width: wFrac * screenImage.width,
            height: hFrac * screenImage.height,
        };
    }, [screenImage, screen?.crop]);
    const sSrcW = screenCrop?.width ?? screenImage?.width ?? 0;
    const sSrcH = screenCrop?.height ?? screenImage?.height ?? 0;
    let sDrawX = 0;
    let sDrawY = 0;
    let sDrawW = innerW;
    let sDrawH = innerH;
    if (screenImage && (sFit === 'cover' || sFit === 'contain')) {
        const box = fitBoxDims(sSrcW, sSrcH, innerW, innerH, sFit, sZoom, sOffX, sOffY);
        sDrawX = box.x;
        sDrawY = box.y;
        sDrawW = box.width;
        sDrawH = box.height;
    }

    const screenFilters = resolveImageAdjust(screen?.adjust);
    const screenImageRef = useRef<Konva.Image>(null);
    const screenFilterKey = screenFilters.filters
        .map((f) => (typeof f === 'function' ? f.name : String(f)))
        .join('|');
    useEffect(() => {
        const node = screenImageRef.current;
        if (!node) return;
        if (screenFilters.filters.length > 0 && screenImage) {
            node.cache();
        } else {
            node.clearCache();
        }
    }, [
        screenImage,
        sDrawW,
        sDrawH,
        screenFilterKey,
        screenFilters.filters.length,
        screenCrop?.x,
        screenCrop?.y,
        screenCrop?.width,
        screenCrop?.height,
    ]);

    const flipScaleX = flipX ? -1 : 1;
    const flipScaleY = flipY ? -1 : 1;
    const flipOffsetX = flipX ? rect.width : 0;
    const flipOffsetY = flipY ? rect.height : 0;

    // --- Real device frame (photorealistic PNG) branch ------------------
    //
    // Fires when the layer opted in (`realFrameEnabled`), the device preset
    // ships a frame bundle (`has_real_frame`), AND any 3D tilt stays inside
    // the ±15°/axis cap (`realFrameTiltAllowed`). Tilt beyond the cap routes
    // back to the procedural strip-renderer below — the PNG's photoreal
    // edges (camera bumps, speaker grilles) start showing strip-aliasing
    // past that bound, while ≤15° stays perceptually clean. Inside the cap
    // we composite chassis + screen into a single offscreen and run the
    // SAME strip-trapezoid sampler as the procedural path so screen + bezel
    // recede as one continuous plane.
    //
    // Asset URL: `/device-frames/{familyKey}/{color}/{orientation}.png`.
    //   familyKey  — devicePreset.key with `-landscape` suffix stripped, so
    //                portrait + landscape variants share one bundle dir
    //                (manifest carries both orientations side-by-side).
    //   color      — `layer.realFrameColor` if it matches a slug in
    //                `real_frame_color_options`; else the first option.
    //   orientation — derived from devicePreset.key/form_factor.
    //
    // Frame-dims source: the manifest's `screen_rect` coordinates are in
    // PNG-native pixel space, which we read off the loaded `frameImage`'s
    // `naturalWidth / naturalHeight`. This self-corrects against any
    // manifest drift (e.g. an admin replaces the PNG with a higher-res
    // variant) without needing to surface `frame_dims` through the DTO or
    // re-run a backfill. We fall back to `devicePreset.export_w/h` only
    // before the PNG has loaded — `screen_rect` won't render until the
    // image is ready, so the fallback only affects the "no image" branch
    // where scale isn't observable anyway.
    const orientation: 'portrait' | 'landscape' =
        devicePreset?.key.endsWith('-landscape') ||
        (devicePreset?.form_factor?.includes('landscape') ?? false)
            ? 'landscape'
            : 'portrait';
    const familyKey = devicePreset?.key.replace(/-landscape$/, '') ?? '';
    const colorOptions = devicePreset?.real_frame_color_options ?? null;
    const pickedColorSlug =
        colorOptions && colorOptions.length > 0
            ? colorOptions.find((c) => c.slug === layer.realFrameColor)?.slug ??
              colorOptions[0].slug
            : undefined;
    const realFrameRect = devicePreset?.real_frame_screen_rect?.[orientation] ?? null;
    // Preliminary activation — gates whether we even attempt the PNG load.
    // The final `useRealFrame` below tightens this with a load-failure check
    // so a 404 / network error falls through to the procedural path (mirrors
    // the export pipeline's cache-miss fallback in renderScreen.ts).
    const realFrameRequested =
        layer.realFrameEnabled === true &&
        devicePreset?.has_real_frame === true &&
        (!hasTilt || realFrameTiltAllowed(tiltX, tiltY)) &&
        realFrameRect != null &&
        pickedColorSlug != null &&
        familyKey !== '';
    const frameUrl = realFrameRequested
        ? `/device-frames/${familyKey}/${pickedColorSlug}/${orientation}.png`
        : undefined;
    const { image: frameImage, status: frameImageStatus } = useImage(frameUrl);
    const realFrameFailed = frameImageStatus === 'error';
    const useRealFrame = realFrameRequested && !realFrameFailed;

    // Scaled screen-cutout coords inside the real frame. Source of truth for
    // frame_dims is the loaded image's natural size; while it's loading we
    // read the persisted `frame_w/frame_h` off `real_frame_screen_rect` so
    // the screen-cutout sits at the right coords from frame one. Falling
    // back to `export_w/h` (the SCREEN dims, not the PNG dims) made the
    // screen rect snap position the moment the PNG resolved.
    const frameNaturalW =
        frameImage?.naturalWidth ||
        realFrameRect?.frame_w ||
        devicePreset?.export_w ||
        1;
    const frameNaturalH =
        frameImage?.naturalHeight ||
        realFrameRect?.frame_h ||
        devicePreset?.export_h ||
        1;
    const rfScaleX = useRealFrame ? rect.width / frameNaturalW : 1;
    const rfScaleY = useRealFrame ? rect.height / frameNaturalH : 1;
    const rfScreenX = realFrameRect ? realFrameRect.x * rfScaleX : 0;
    const rfScreenY = realFrameRect ? realFrameRect.y * rfScaleY : 0;
    const rfScreenW = realFrameRect ? realFrameRect.width * rfScaleX : 0;
    const rfScreenH = realFrameRect ? realFrameRect.height * rfScaleY : 0;
    const rfScreenRadius = realFrameRect
        ? realFrameRect.radius * Math.min(rfScaleX, rfScaleY)
        : 0;

    // Real-frame screen draw box (the procedural sDrawX/Y/W/H are computed
    // against innerW/innerH and would mis-fit inside the real frame's
    // narrower cutout — recompute against rfScreenW/H here).
    let rfDrawX = 0;
    let rfDrawY = 0;
    let rfDrawW = rfScreenW;
    let rfDrawH = rfScreenH;
    if (useRealFrame && screenImage && (sFit === 'cover' || sFit === 'contain')) {
        const box = fitBoxDims(sSrcW, sSrcH, rfScreenW, rfScreenH, sFit, sZoom, sOffX, sOffY);
        rfDrawX = box.x;
        rfDrawY = box.y;
        rfDrawW = box.width;
        rfDrawH = box.height;
    }

    // Filter cache for the real-frame screen image (separate ref so the
    // procedural and real-frame paths don't fight over a shared node).
    const rfScreenImageRef = useRef<Konva.Image>(null);
    useEffect(() => {
        if (!useRealFrame) return;
        const node = rfScreenImageRef.current;
        if (!node) return;
        if (screenFilters.filters.length > 0 && screenImage) {
            node.cache();
        } else {
            node.clearCache();
        }
    }, [
        useRealFrame,
        screenImage,
        rfDrawW,
        rfDrawH,
        screenFilterKey,
        screenFilters.filters.length,
        screenCrop?.x,
        screenCrop?.y,
        screenCrop?.width,
        screenCrop?.height,
    ]);

    if (useRealFrame && hasTilt) {
        // Tilted real-frame branch (±15°/axis). Composite the entire device
        // mockup — screen fill + screenshot + status-bar overlay + chassis PNG
        // — into ONE offscreen canvas at the layer rect dims, then feed that
        // bitmap through the same strip-trapezoid sampler the procedural path
        // uses (`drawTilt`). Sampling one composite (instead of strip-tilting
        // each layer separately and stacking) guarantees the chassis + screen
        // recede on a single plane with no parallax misalignment. Konva image
        // filters on the screen content are skipped under tilt to match the
        // documented image-branch contract (Konva filters need a native Image
        // node; the composite is a plain HTMLCanvasElement).
        const realFrameOffscreen = buildRealFrameOffscreen({
            layerWidth: rect.width,
            layerHeight: rect.height,
            frameImage,
            screenImage,
            screenFillColor,
            screenRect: {
                x: rfScreenX,
                y: rfScreenY,
                width: rfScreenW,
                height: rfScreenH,
                radius: rfScreenRadius,
            },
            screenDraw: { x: rfDrawX, y: rfDrawY, width: rfDrawW, height: rfDrawH },
            screenCrop,
            statusBarOverlay: screen?.statusBarOverlay,
        });
        return (
            <Group
                ref={forwardedRef as React.Ref<Konva.Group>}
                {...commonShape}
                width={rect.width}
                height={rect.height}
                skewX={skewX}
                skewY={skewY}
            >
                {/* Drop shadow under the tilted device — cast from the frame
                    PNG pre-tilted into one offscreen bitmap, so the halo hugs
                    the real rounded silhouette (not a boxy trapezoid the size
                    of the full layer rect). The caster is covered exactly by
                    the tilted content pass below; only the offset halo shows. */}
                {frameImage &&
                    frameShadows.length > 0 &&
                    (() => {
                        const sil = buildTiltedFrameSilhouette(
                            frameImage,
                            rect.width,
                            rect.height,
                            tiltX,
                            tiltY,
                        );
                        return (
                            sil &&
                            withShadows(frameShadows, (shadowProps, _isPrimary, key) => (
                                <KonvaImage
                                    key={key ?? 'tilt-frame-shadow'}
                                    image={sil}
                                    x={flipOffsetX}
                                    y={flipOffsetY}
                                    scaleX={flipScaleX}
                                    scaleY={flipScaleY}
                                    width={rect.width}
                                    height={rect.height}
                                    {...shadowProps}
                                    listening={false}
                                />
                            ))
                        );
                    })()}
                {realFrameOffscreen && (
                    <KonvaShape
                        x={flipOffsetX}
                        y={flipOffsetY}
                        width={rect.width}
                        height={rect.height}
                        scaleX={flipScaleX}
                        scaleY={flipScaleY}
                        listening={false}
                        sceneFunc={(ctx, shape) => {
                            drawTilt(
                                ctx as unknown as CanvasRenderingContext2D,
                                realFrameOffscreen,
                                shape.width(),
                                shape.height(),
                                tiltX,
                                tiltY,
                            );
                        }}
                    />
                )}
                {/* Hit target — keeps the whole layer rect selectable, same as
                    the flat path; the tilted silhouette shrinks inside the
                    rect so we keep the full bounding box clickable. */}
                <Rect
                    x={0}
                    y={0}
                    width={rect.width}
                    height={rect.height}
                    fill="rgba(0,0,0,0)"
                />
            </Group>
        );
    }

    if (useRealFrame) {
        return (
            <Group
                ref={forwardedRef as React.Ref<Konva.Group>}
                {...commonShape}
                width={rect.width}
                height={rect.height}
                skewX={skewX}
                skewY={skewY}
            >
                {/* Drop shadow that hugs the REAL device silhouette. We cast
                    it from the frame PNG's own alpha (a duplicate image drawn
                    behind everything), so the halo follows the photoreal
                    rounded-corner outline + transparent outer margins — not a
                    boxy full-rect rounded approximation. The caster's own
                    pixels are covered exactly by the top frame PNG, so only the
                    offset shadow overhang shows. One copy per shadow. */}
                {frameImage &&
                    frameShadows.length > 0 &&
                    withShadows(frameShadows, (shadowProps, _isPrimary, key) => (
                        <KonvaImage
                            key={key ?? 'frame-shadow'}
                            image={frameImage}
                            x={flipOffsetX}
                            y={flipOffsetY}
                            scaleX={flipScaleX}
                            scaleY={flipScaleY}
                            width={rect.width}
                            height={rect.height}
                            {...shadowProps}
                            listening={false}
                        />
                    ))}
                <Group
                    x={flipOffsetX}
                    y={flipOffsetY}
                    scaleX={flipScaleX}
                    scaleY={flipScaleY}
                    width={rect.width}
                    height={rect.height}
                    listening={false}
                >
                    {/* Screen fill confined to the manifest's screen_rect.
                        Cannot span the full layer rect — Apple's marketing
                        PNGs have transparent OUTER bleed at the device's
                        rounded-corner silhouette, and a full-rect fill would
                        show through as a white halo around the chassis.
                        The corrected screen_rect (`screen_rect.{portrait,
                        landscape}` from the manifest, sized to the device's
                        full canvas dims centered in the PNG) covers all
                        interior transparent areas including the dynamic-
                        island carve-out. Areas where the rect overshoots
                        the chassis edge at the corners are safely hidden
                        behind the opaque chassis bezel. */}
                    <Rect
                        x={rfScreenX}
                        y={rfScreenY}
                        width={rfScreenW}
                        height={rfScreenH}
                        {...colorToKonvaFill(screenFillColor, rfScreenW, rfScreenH)}
                        cornerRadius={rfScreenRadius}
                        listening={false}
                    />
                    {/* Bound screenshot — clipped to the rounded-rect cutout
                        so corner radius matches the PNG's screen mask. The
                        clip only applies inside the cutout; the frame PNG on
                        top hides any overdraw at the edges anyway. */}
                    {screenImage && (
                        <Group
                            x={rfScreenX}
                            y={rfScreenY}
                            width={rfScreenW}
                            height={rfScreenH}
                            listening={false}
                            clipFunc={(ctx) => {
                                roundedRectPath(
                                    ctx,
                                    0,
                                    0,
                                    rfScreenW,
                                    rfScreenH,
                                    rfScreenRadius,
                                );
                            }}
                        >
                            <KonvaImage
                                ref={rfScreenImageRef}
                                image={screenImage}
                                x={rfDrawX}
                                y={rfDrawY}
                                width={rfDrawW}
                                height={rfDrawH}
                                crop={screenCrop}
                                filters={
                                    screenFilters.filters.length > 0
                                        ? screenFilters.filters
                                        : undefined
                                }
                                {...screenFilters.attrs}
                                listening={false}
                            />
                        </Group>
                    )}
                    {/* Hide-status-bar overlay — paints a colored rect over
                        the screenshot's top edge. Wrapped in a Group with the
                        same rounded clipFunc as the screenshot so the overlay
                        follows the screen's rounded corners and doesn't bleed
                        into the PNG's outer-bleed transparency at the device
                        silhouette. Renders AFTER the screenshot so it
                        covers the status bar pixels. */}
                    {screen?.statusBarOverlay?.enabled && (
                        <Group
                            x={rfScreenX}
                            y={rfScreenY}
                            width={rfScreenW}
                            height={rfScreenH}
                            listening={false}
                            clipFunc={(ctx) => {
                                roundedRectPath(
                                    ctx,
                                    0,
                                    0,
                                    rfScreenW,
                                    rfScreenH,
                                    rfScreenRadius,
                                );
                            }}
                        >
                            <Rect
                                x={0}
                                y={0}
                                width={rfScreenW}
                                height={
                                    rfScreenH *
                                    Math.max(
                                        0,
                                        Math.min(
                                            0.3,
                                            (screen.statusBarOverlay.heightPct ?? 6) / 100,
                                        ),
                                    )
                                }
                                fill={screen.statusBarOverlay.color ?? '#000000'}
                                listening={false}
                            />
                        </Group>
                    )}
                    {/* Frame PNG on top — its transparent screen cutout lets
                        the screenshot show through. Until the image loads
                        (`frameImage` undefined) we leave the slot empty; the
                        screenshot still renders beneath, so the user sees
                        their content immediately and the chassis pops in
                        when the asset arrives. */}
                    {frameImage && (
                        <KonvaImage
                            image={frameImage}
                            x={0}
                            y={0}
                            width={rect.width}
                            height={rect.height}
                            listening={false}
                        />
                    )}
                </Group>
                {/* Hit target — keeps the whole layer rect selectable, same
                    as the procedural path. */}
                <Rect
                    x={0}
                    y={0}
                    width={rect.width}
                    height={rect.height}
                    fill="rgba(0,0,0,0)"
                />
                {/* CTA renders AFTER the hit rect for last-first hit-test, so
                    clicks on the empty screen area open the upload picker
                    instead of selecting the frame. */}
                {!screenImage && onRequestFrameScreenUpload && (
                    <EmptyFrameScreenCTA
                        layerId={layer.id}
                        x={rfScreenX}
                        y={rfScreenY}
                        width={rfScreenW}
                        height={rfScreenH}
                        radius={rfScreenRadius}
                        recommendedW={devicePreset?.export_w}
                        recommendedH={devicePreset?.export_h}
                        devicePresetLabel={devicePreset?.label}
                        onClick={onRequestFrameScreenUpload}
                    />
                )}
            </Group>
        );
    }

    // Common content — rendered either flat (no tilt) or used as the source for
    // a tilted trapezoid via drawTilt over an offscreen-cached canvas.
    const renderFlat = (key: string) => (
        <Group
            key={key}
            x={flipOffsetX}
            y={flipOffsetY}
            scaleX={flipScaleX}
            scaleY={flipScaleY}
            width={rect.width}
            height={rect.height}
            listening={false}
        >
            <Rect
                width={rect.width}
                height={rect.height}
                {...chassisFill}
                {...(chassisStrokeProps ?? {})}
                cornerRadius={bezel.chassisRadius}
                listening={false}
            />
            <Rect
                x={bezel.inset}
                y={bezel.inset}
                width={innerW}
                height={innerH}
                {...screenFill}
                cornerRadius={innerRadius}
                listening={false}
            />
            {screenImage && (
                <Group
                    x={bezel.inset}
                    y={bezel.inset}
                    width={innerW}
                    height={innerH}
                    listening={false}
                    clipFunc={(ctx) => {
                        roundedRectPath(ctx, 0, 0, innerW, innerH, innerRadius);
                    }}
                >
                    <KonvaImage
                        ref={screenImageRef}
                        image={screenImage}
                        x={sDrawX}
                        y={sDrawY}
                        width={sDrawW}
                        height={sDrawH}
                        crop={screenCrop}
                        filters={
                            screenFilters.filters.length > 0
                                ? screenFilters.filters
                                : undefined
                        }
                        {...screenFilters.attrs}
                        listening={false}
                    />
                </Group>
            )}
            {/* Hide-status-bar overlay (procedural path mirror). Same data
                shape + same clipping strategy as the real-frame branch —
                wrapped in a Group with the chassis inner rounded mask so the
                overlay follows the screen's rounded corners. */}
            {screen?.statusBarOverlay?.enabled && (
                <Group
                    x={bezel.inset}
                    y={bezel.inset}
                    width={innerW}
                    height={innerH}
                    listening={false}
                    clipFunc={(ctx) => {
                        roundedRectPath(ctx, 0, 0, innerW, innerH, innerRadius);
                    }}
                >
                    <Rect
                        x={0}
                        y={0}
                        width={innerW}
                        height={
                            innerH *
                            Math.max(
                                0,
                                Math.min(
                                    0.3,
                                    (screen.statusBarOverlay.heightPct ?? 6) / 100,
                                ),
                            )
                        }
                        fill={screen.statusBarOverlay.color ?? '#000000'}
                        listening={false}
                    />
                </Group>
            )}
            {bezel.island && (
                <Rect
                    x={(rect.width - bezel.island.width) / 2}
                    y={bezel.island.y}
                    width={bezel.island.width}
                    height={bezel.island.height}
                    fill="#0F172A"
                    cornerRadius={bezel.island.height / 2}
                    listening={false}
                />
            )}
        </Group>
    );

    // Hit target — keeps the whole frame rect clickable even when the chassis
    // shrinks under tilt (the trapezoid silhouette can leave dead zones inside
    // the layer rect that should still select the frame).
    const hitTarget = (
        <Rect
            x={0}
            y={0}
            width={rect.width}
            height={rect.height}
            fill="rgba(0,0,0,0)"
        />
    );

    if (!hasTilt) {
        return (
            <Group
                ref={forwardedRef as React.Ref<Konva.Group>}
                {...commonShape}
                width={rect.width}
                height={rect.height}
                skewX={skewX}
                skewY={skewY}
            >
                {/* Drop shadow on the chassis silhouette only (so the screen
                    image doesn't get its own shadow ring inside the bezel). */}
                {withShadows(frameShadows, (shadowProps, isPrimary, key) =>
                    isPrimary ? null : (
                        <Rect
                            key={key ?? undefined}
                            x={flipOffsetX}
                            y={flipOffsetY}
                            scaleX={flipScaleX}
                            scaleY={flipScaleY}
                            width={rect.width}
                            height={rect.height}
                            cornerRadius={bezel.chassisRadius}
                            fill="#000"
                            {...shadowProps}
                            listening={false}
                        />
                    ),
                )}
                {/* Primary shadow lives on the chassis Rect so it composites
                    correctly under the screen image. We render the chassis +
                    screen + island as one flat group, then add a separate
                    shadow caster behind it for the primary shadow. */}
                {frameShadows.length > 0 && (
                    <Rect
                        x={flipOffsetX}
                        y={flipOffsetY}
                        scaleX={flipScaleX}
                        scaleY={flipScaleY}
                        width={rect.width}
                        height={rect.height}
                        cornerRadius={bezel.chassisRadius}
                        fill="#000"
                        {...toKonvaShadowProps(frameShadows[frameShadows.length - 1])}
                        listening={false}
                    />
                )}
                {renderFlat('frame-flat')}
                {hitTarget}
                {/* CTA renders AFTER hitTarget so Konva's hit-test (last-first)
                    catches it before falling through to the frame-select rect. */}
                {!screenImage && onRequestFrameScreenUpload && (
                    <EmptyFrameScreenCTA
                        layerId={layer.id}
                        x={bezel.inset}
                        y={bezel.inset}
                        width={innerW}
                        height={innerH}
                        radius={innerRadius}
                        recommendedW={devicePreset?.export_w}
                        recommendedH={devicePreset?.export_h}
                        devicePresetLabel={devicePreset?.label}
                        onClick={onRequestFrameScreenUpload}
                    />
                )}
            </Group>
        );
    }

    // Tilted: render in two passes so the chassis silhouette stays crisp.
    //
    //   Pass 1 — chassis fill as a clean trapezoid path. Solid color directly
    //   drawn along the path means the silhouette is anti-aliased once (by
    //   Canvas2D's path-stroke filter), not stripped. Strip-rendering a flat
    //   chassis was the source of visible horizontal banding along the tilted
    //   edge: each strip has a slightly different left x-position, so the
    //   silhouette stair-steps even under a trapezoid clip.
    //
    //   Pass 2 — screen content (fill + image + island) strip-rendered from a
    //   chassis-less offscreen, clipped to the same trapezoid so it stays
    //   contained within the chassis silhouette. Strip artifacts on the
    //   screen content are masked by varied image pixels and by the chassis
    //   inset (the screen never touches the outer chassis edge).
    const chassisColorStr =
        typeof chassisFill.fill === 'string' ? (chassisFill.fill as string) : '#0F172A';
    const screenOffscreen = buildFrameScreenOffscreen({
        width: rect.width,
        height: rect.height,
        bezel,
        innerW,
        innerH,
        innerRadius,
        screenFillColor,
        screenImage,
        screenDraw: { x: sDrawX, y: sDrawY, width: sDrawW, height: sDrawH },
        screenCrop,
    });

    return (
        <Group
            ref={forwardedRef as React.Ref<Konva.Group>}
            {...commonShape}
            width={rect.width}
            height={rect.height}
            skewX={skewX}
            skewY={skewY}
        >
            {frameShadows.length > 0 && (
                <KonvaShape
                    width={rect.width}
                    height={rect.height}
                    sceneFunc={(ctx, node) => {
                        buildTrapezoidPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rect.width,
                            rect.height,
                            tiltX,
                            tiltY,
                            bezel.chassisRadius,
                        );
                        ctx.fillStrokeShape(node);
                    }}
                    fill="#000"
                    {...toKonvaShadowProps(frameShadows[frameShadows.length - 1])}
                    listening={false}
                />
            )}
            {/* Pass 1: chassis path-fill */}
            <KonvaShape
                x={flipOffsetX}
                y={flipOffsetY}
                width={rect.width}
                height={rect.height}
                scaleX={flipScaleX}
                scaleY={flipScaleY}
                listening={false}
                sceneFunc={(ctx) => {
                    const c = ctx as unknown as CanvasRenderingContext2D;
                    buildTrapezoidPath(
                        c,
                        0,
                        0,
                        rect.width,
                        rect.height,
                        tiltX,
                        tiltY,
                        bezel.chassisRadius,
                    );
                    c.fillStyle = chassisColorStr;
                    c.fill();
                    if (chassisStrokeProps) {
                        const sw = (chassisStrokeProps.strokeWidth as number) ?? 0;
                        if (sw > 0) {
                            c.strokeStyle =
                                typeof chassisStrokeProps.stroke === 'string'
                                    ? (chassisStrokeProps.stroke as string)
                                    : '#000000';
                            c.lineWidth = sw;
                            c.stroke();
                        }
                    }
                }}
            />
            {/* Pass 2: screen content (offscreen rasterized at INNER dims, no
                bezel padding baked in), strip-rendered inside an INNER
                trapezoid clip. Pass 3 below overlays a uniform-thickness
                bezel stroke so the visible bezel stays `bezel.inset` thick
                regardless of perspective compression. */}
            <Group
                width={rect.width}
                height={rect.height}
                listening={false}
                clipFunc={(ctx) => {
                    buildTrapezoidPath(
                        ctx as unknown as CanvasRenderingContext2D,
                        bezel.inset,
                        bezel.inset,
                        innerW,
                        innerH,
                        tiltX,
                        tiltY,
                        innerRadius,
                    );
                }}
            >
                <KonvaShape
                    x={flipX ? rect.width - bezel.inset : bezel.inset}
                    y={flipY ? rect.height - bezel.inset : bezel.inset}
                    width={innerW}
                    height={innerH}
                    scaleX={flipScaleX}
                    scaleY={flipScaleY}
                    listening={false}
                    sceneFunc={(ctx, shape) => {
                        if (!screenOffscreen) return;
                        drawTilt(
                            ctx as unknown as CanvasRenderingContext2D,
                            screenOffscreen,
                            shape.width(),
                            shape.height(),
                            tiltX,
                            tiltY,
                        );
                    }}
                />
            </Group>
            {/* Pass 3: uniform-thickness bezel stroke overlay. Clipping to the
                outer trapezoid path keeps only the INSIDE half of the stroke
                visible — lineWidth = 2*bezel.inset → visible band = exactly
                bezel.inset thick along every edge of the path, regardless of
                perspective compression (which would otherwise yield
                bezel.inset * cos(θ) thickness on compressed sides). */}
            <KonvaShape
                width={rect.width}
                height={rect.height}
                listening={false}
                sceneFunc={(ctx) => {
                    const c = ctx as unknown as CanvasRenderingContext2D;
                    c.save();
                    buildTrapezoidPath(
                        c,
                        0,
                        0,
                        rect.width,
                        rect.height,
                        tiltX,
                        tiltY,
                        bezel.chassisRadius,
                    );
                    c.clip();
                    buildTrapezoidPath(
                        c,
                        0,
                        0,
                        rect.width,
                        rect.height,
                        tiltX,
                        tiltY,
                        bezel.chassisRadius,
                    );
                    c.strokeStyle = chassisColorStr;
                    c.lineWidth = bezel.inset * 2;
                    c.lineJoin = 'round';
                    c.lineCap = 'round';
                    c.stroke();
                    c.restore();
                }}
            />
            {hitTarget}
        </Group>
    );
}

/**
 * "Add screenshot" CTA rendered inside an empty frame's screen area.
 * Renders only when the parent passed an upload-request callback (main
 * editor canvas) AND the frame has no bound screen image AND the layer
 * isn't tilted (tilt+empty is rare; CTA suppressed for that edge).
 *
 * Click target is a transparent rect covering the inner screen area;
 * label is a small camera glyph + "Add screenshot" sentence-case text.
 * Cursor flips to pointer on hover for the affordance.
 */
function EmptyFrameScreenCTA({
    layerId,
    x,
    y,
    width,
    height,
    radius,
    recommendedW,
    recommendedH,
    devicePresetLabel,
    onClick,
}: {
    layerId: string;
    x: number;
    y: number;
    width: number;
    height: number;
    radius: number;
    /** Device preset's native export dimensions — surfaced as a recommended
     *  screenshot size hint below the CTA so users know what to capture. */
    recommendedW?: number;
    recommendedH?: number;
    /** Device preset's label (e.g. "iPhone 16 Pro Max") — small caption under
     *  the dimensions so users know WHICH device the recommended size is for. */
    devicePresetLabel?: string;
    onClick: (layerId: string) => void;
}) {
    if (width <= 0 || height <= 0) return null;
    // Font sized in CANVAS pixels (native resolution). Editor zoom is
    // typically 25-40% on a phone-sized frame, so use ~7% of the inner
    // shorter axis (no cap) so it renders ~24-40px in the viewport.
    const labelFontSize = Math.max(28, Math.round(Math.min(width, height) * 0.07));
    // Dimensions are the actionable info — render them bigger than the CTA
    // label so the user can read them at a glance from across the editor.
    const hintFontSize = Math.max(48, Math.round(labelFontSize * 1.6));
    const captionFontSize = Math.max(22, Math.round(labelFontSize * 0.75));
    const hintGap = Math.round(labelFontSize * 0.7);
    const captionGap = Math.round(labelFontSize * 0.5);
    const hasHint = recommendedW != null && recommendedH != null && recommendedW > 0 && recommendedH > 0;
    const hasCaption = hasHint && typeof devicePresetLabel === 'string' && devicePresetLabel.length > 0;
    const totalH =
        labelFontSize
        + (hasHint ? hintGap + hintFontSize : 0)
        + (hasCaption ? captionGap + captionFontSize : 0);
    const labelY = height / 2 - totalH / 2;
    const hintY = labelY + labelFontSize + hintGap;
    const captionY = hintY + hintFontSize + captionGap;
    // Dashed inset rect telegraphs "drop zone" without competing visually
    // with the chassis. Scaled stroke + dash so it's visible at typical
    // editor zooms.
    const inset = Math.max(8, Math.round(Math.min(width, height) * 0.03));
    const strokeWidth = Math.max(2, Math.round(Math.min(width, height) * 0.004));
    const dash = Math.max(8, Math.round(Math.min(width, height) * 0.018));
    return (
        <Group
            x={x}
            y={y}
            width={width}
            height={height}
            listening
            onMouseEnter={(e) => {
                const stage = e.target.getStage();
                if (stage) stage.container().style.cursor = 'pointer';
            }}
            onMouseLeave={(e) => {
                const stage = e.target.getStage();
                if (stage) stage.container().style.cursor = '';
            }}
            onClick={(e) => {
                e.cancelBubble = true;
                onClick(layerId);
            }}
            onTap={(e) => {
                e.cancelBubble = true;
                onClick(layerId);
            }}
        >
            {/* Full-area click target — transparent fill so visible chrome is
                only the dashed inset rect + label. */}
            <Rect
                width={width}
                height={height}
                fill="rgba(0,0,0,0)"
                cornerRadius={radius}
                listening
            />
            {/* Drop-zone indicator: dashed inset rect inside the screen area. */}
            <Rect
                x={inset}
                y={inset}
                width={width - inset * 2}
                height={height - inset * 2}
                fillEnabled={false}
                stroke="#A8A29D"
                strokeWidth={strokeWidth}
                dash={[dash, dash]}
                cornerRadius={Math.max(0, radius - inset)}
                listening={false}
            />
            <Text
                x={0}
                y={labelY}
                width={width}
                align="center"
                text="+ Add screenshot"
                fontFamily={fontStack('sans')}
                fontSize={labelFontSize}
                fontStyle="500"
                fill="#6B6358"
                listening={false}
            />
            {hasHint && (
                <Text
                    x={0}
                    y={hintY}
                    width={width}
                    align="center"
                    text={`${recommendedW} × ${recommendedH}`}
                    fontFamily={fontStack('sans')}
                    fontSize={hintFontSize}
                    fontStyle="600"
                    fill="#3F3A35"
                    listening={false}
                />
            )}
            {hasCaption && (
                <Text
                    x={0}
                    y={captionY}
                    width={width}
                    align="center"
                    text={devicePresetLabel!}
                    fontFamily={fontStack('sans')}
                    fontSize={captionFontSize}
                    fill="#A8A29D"
                    listening={false}
                />
            )}
        </Group>
    );
}

type FrameScreenOffscreenInput = {
    width: number;
    height: number;
    bezel: ReturnType<typeof bezelGeometry>;
    innerW: number;
    innerH: number;
    innerRadius: number;
    screenFillColor: Color;
    screenImage: HTMLImageElement | undefined;
    screenDraw: { x: number; y: number; width: number; height: number };
    screenCrop: { x: number; y: number; width: number; height: number } | undefined;
};

/**
 * Rasterize JUST the screen content (fill + image + island) — no chassis.
 * Output is consumed by drawTilt in the tilted frame branch: the chassis
 * silhouette is drawn separately via a clean trapezoid path-fill (no strips)
 * so its edge stays crisp, and this strip-rendered overlay sits on top
 * inside the trapezoid clip. Strip artifacts inside the screen are masked
 * by varied image pixels and by the chassis inset (screen never touches
 * the outer silhouette).
 */
function buildFrameScreenOffscreen(input: FrameScreenOffscreenInput): HTMLCanvasElement | null {
    const { bezel, innerW, innerH, innerRadius } = input;
    if (innerW <= 0 || innerH <= 0) return null;
    // Canvas is sized to the INNER rect, not the full layer rect — no bezel
    // padding baked into the source. drawTilt then operates on the screen
    // area directly, so the visible bezel (gap between chassis trapezoid and
    // inner clip) stays a uniform thickness in destination pixels instead of
    // being compressed by the strip rendering at perspective-shrunk strips.
    const w = Math.max(1, Math.ceil(innerW));
    const h = Math.max(1, Math.ceil(innerH));
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';

    const screenColor =
        input.screenFillColor.type === 'solid'
            ? input.screenFillColor.value
            : input.screenFillColor.stops?.[0]?.color ?? '#F4F4F5';
    ctx.save();
    roundedRectPath(ctx, 0, 0, w, h, innerRadius);
    ctx.clip();
    ctx.fillStyle = screenColor;
    ctx.fillRect(0, 0, w, h);
    if (input.screenImage) {
        const sx = input.screenCrop?.x ?? 0;
        const sy = input.screenCrop?.y ?? 0;
        const sw = input.screenCrop?.width ?? input.screenImage.width;
        const sh = input.screenCrop?.height ?? input.screenImage.height;
        ctx.drawImage(
            input.screenImage,
            sx,
            sy,
            sw,
            sh,
            input.screenDraw.x,
            input.screenDraw.y,
            input.screenDraw.width,
            input.screenDraw.height,
        );
    }
    ctx.restore();

    if (bezel.island) {
        // Island is drawn relative to the chassis (full layer rect) — its
        // position is `(layerW - islandWidth)/2, bezel.island.y`. To draw it
        // in the inner-only offscreen, subtract the inset offset so the
        // island still aligns with the chassis top when the inner offscreen
        // is translated by bezel.inset at render time.
        const ix = (input.width - bezel.island.width) / 2 - bezel.inset;
        const iy = bezel.island.y - bezel.inset;
        const ir = bezel.island.height / 2;
        roundedRectPath(ctx, ix, iy, bezel.island.width, bezel.island.height, ir);
        ctx.fillStyle = '#0F172A';
        ctx.fill();
    }
    return canvas;
}

type RealFrameOffscreenInput = {
    layerWidth: number;
    layerHeight: number;
    frameImage: HTMLImageElement | undefined;
    screenImage: HTMLImageElement | undefined;
    screenFillColor: Color;
    screenRect: { x: number; y: number; width: number; height: number; radius: number };
    screenDraw: { x: number; y: number; width: number; height: number };
    screenCrop: { x: number; y: number; width: number; height: number } | undefined;
    statusBarOverlay:
        | { enabled: boolean; color?: string; heightPct?: number }
        | undefined;
};

/**
 * Composite the entire real-frame device mockup — screen fill, screenshot,
 * status-bar overlay, photoreal chassis PNG — into ONE offscreen canvas at
 * the layer rect's dimensions. The caller feeds this single bitmap through
 * `drawTilt`, so the strip-trapezoid sampler sees the device as one
 * continuous plane (no per-layer parallax misalignment between bezel and
 * screen). Mirrored byte-for-byte in `export/renderScreen.ts` so editor
 * preview matches the exported ZIP. Returns null when the layer dims are
 * degenerate.
 */
export function buildRealFrameOffscreen(
    input: RealFrameOffscreenInput,
): HTMLCanvasElement | null {
    const w = Math.max(1, Math.ceil(input.layerWidth));
    const h = Math.max(1, Math.ceil(input.layerHeight));
    if (w <= 1 && h <= 1) return null;
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';

    const { screenRect, screenDraw, screenCrop } = input;
    const screenColor =
        input.screenFillColor.type === 'solid'
            ? input.screenFillColor.value
            : input.screenFillColor.stops?.[0]?.color ?? '#F4F4F5';

    // Screen content goes down first, clipped to the manifest's rounded
    // screen cutout. The chassis PNG (drawn last) covers any overdraw at
    // the cutout's outer edge via its opaque silhouette.
    if (screenRect.width > 0 && screenRect.height > 0) {
        ctx.save();
        ctx.translate(screenRect.x, screenRect.y);
        roundedRectPath(ctx, 0, 0, screenRect.width, screenRect.height, screenRect.radius);
        ctx.clip();
        ctx.fillStyle = screenColor;
        ctx.fillRect(0, 0, screenRect.width, screenRect.height);
        if (input.screenImage) {
            const sx = screenCrop?.x ?? 0;
            const sy = screenCrop?.y ?? 0;
            const sw = screenCrop?.width ?? input.screenImage.width;
            const sh = screenCrop?.height ?? input.screenImage.height;
            ctx.drawImage(
                input.screenImage,
                sx,
                sy,
                sw,
                sh,
                screenDraw.x,
                screenDraw.y,
                screenDraw.width,
                screenDraw.height,
            );
        }
        // Status-bar overlay sits on top of the screenshot but inside the
        // same rounded clip, so it doesn't bleed into the PNG's outer
        // transparency at the device silhouette.
        const sbo = input.statusBarOverlay;
        if (sbo?.enabled) {
            const heightPct = Math.max(0, Math.min(0.3, (sbo.heightPct ?? 6) / 100));
            ctx.fillStyle = sbo.color ?? '#000000';
            ctx.fillRect(0, 0, screenRect.width, screenRect.height * heightPct);
        }
        ctx.restore();
    }

    // Chassis PNG on top — its transparent screen cutout lets the screen
    // content show through. Drawn at the full layer rect (matches the
    // un-tilted real-frame branch's KonvaImage).
    if (input.frameImage) {
        ctx.drawImage(input.frameImage, 0, 0, w, h);
    }
    return canvas;
}

/**
 * Tilt ONLY the frame PNG (no screen content) into one offscreen bitmap, sized
 * to the layer rect. Used as a silhouette-accurate shadow caster for the tilted
 * real-frame branch: a single pre-tilted image lets Konva project a clean drop
 * shadow from the device's actual rounded alpha. Casting from a live
 * strip-rendered sceneFunc instead would seam the shadow (canvas shadow is
 * re-applied per drawImage strip), and a plain trapezoid fill ignores the PNG's
 * transparent outer margins → a boxy halo larger than the device.
 */
export function buildTiltedFrameSilhouette(
    frameImage: CanvasImageSource,
    layerWidth: number,
    layerHeight: number,
    tiltX: number,
    tiltY: number,
): HTMLCanvasElement | null {
    const w = Math.max(1, Math.ceil(layerWidth));
    const h = Math.max(1, Math.ceil(layerHeight));
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;
    drawTilt(ctx, frameImage, w, h, tiltX, tiltY);
    return canvas;
}

/**
 * Stroke a vector icon glyph (SVG path `d` strings in a 24×24 viewBox) centered
 * + uniformly scaled into a w×h box, inset so it reads as a badge glyph. Round
 * caps/joins match Lucide's line-icon style. Pure-ctx so the editor sceneFunc
 * and the export renderer share one implementation. `strokeWidth` is in viewBox
 * units (Lucide default 2) — it scales with the glyph.
 */
export function drawIconGlyph(
    ctx: CanvasRenderingContext2D,
    w: number,
    h: number,
    paths: string[],
    color: string,
    strokeWidth = 2,
    inset = 0.3,
): void {
    if (w <= 0 || h <= 0 || paths.length === 0) return;
    const avail = Math.min(w, h) * (1 - 2 * inset);
    const s = avail / 24;
    if (s <= 0) return;
    const ox = (w - 24 * s) / 2;
    const oy = (h - 24 * s) / 2;
    ctx.save();
    ctx.translate(ox, oy);
    ctx.scale(s, s);
    ctx.strokeStyle = color;
    ctx.lineWidth = strokeWidth;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    for (const d of paths) {
        try {
            ctx.stroke(new Path2D(d));
        } catch {
            /* malformed path — skip rather than throw mid-render */
        }
    }
    ctx.restore();
}

/**
 * Draw a FILLED vector graphic into a w×h box: SVG path `d` strings in a
 * `vbW × vbH` viewBox, scaled to CONTAIN (aspect-preserving, centered) and
 * filled with `fill` (solid / linear / radial / pattern). Shared by editor +
 * export. Used for decorative silhouettes (laurel wreaths) — a filled shape,
 * not a stroke. The gradient spans the viewBox so it reads across the graphic.
 */
export function drawVectorGraphic(
    ctx: CanvasRenderingContext2D,
    w: number,
    h: number,
    paths: string[],
    vbW: number,
    vbH: number,
    fill: Color | undefined,
    fillRule: CanvasFillRule = 'nonzero',
): void {
    if (w <= 0 || h <= 0 || vbW <= 0 || vbH <= 0 || paths.length === 0) return;
    const s = Math.min(w / vbW, h / vbH);
    const ox = (w - vbW * s) / 2;
    const oy = (h - vbH * s) / 2;
    ctx.save();
    ctx.translate(ox, oy);
    ctx.scale(s, s);
    ctx.fillStyle = colorToCanvasFill(ctx, fill, vbW, vbH);
    for (const d of paths) {
        try {
            ctx.fill(new Path2D(d), fillRule);
        } catch {
            /* malformed path — skip rather than throw mid-render */
        }
    }
    ctx.restore();
}

/**
 * Draw a complete icon BADGE into a w×h box: a rounded-rect tile (solid fill)
 * with the glyph stroked on top. Shared by editor + export. Shadow is the
 * caller's responsibility (the tile is opaque, so a Konva shadow on the host
 * shape projects from it); the glyph itself casts none (shadow reset inside).
 */
export function drawIconBadge(
    ctx: CanvasRenderingContext2D,
    w: number,
    h: number,
    paths: string[],
    tileColor: string,
    glyphColor: string,
    radius: number,
    glyphStrokeWidth = 2,
): void {
    roundedRectPath(ctx, 0, 0, w, h, radius);
    ctx.fillStyle = tileColor;
    ctx.fill();
    // Glyph gets no shadow of its own — only the tile casts the badge's shadow.
    ctx.shadowColor = 'transparent';
    ctx.shadowBlur = 0;
    drawIconGlyph(ctx, w, h, paths, glyphColor, glyphStrokeWidth);
}

export function roundedRectPath(
    ctx: Konva.Context | CanvasRenderingContext2D,
    x: number,
    y: number,
    w: number,
    h: number,
    r: number,
) {
    const radius = Math.max(0, Math.min(r, w / 2, h / 2));
    ctx.beginPath();
    ctx.moveTo(x + radius, y);
    ctx.lineTo(x + w - radius, y);
    ctx.arcTo(x + w, y, x + w, y + radius, radius);
    ctx.lineTo(x + w, y + h - radius);
    ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
    ctx.lineTo(x + radius, y + h);
    ctx.arcTo(x, y + h, x, y + h - radius, radius);
    ctx.lineTo(x, y + radius);
    ctx.arcTo(x, y, x + radius, y, radius);
    ctx.closePath();
}
