import {
    resolveLocalizedText,
    resolveTextAlign,
    type BlendMode,
    type Canvas,
    type Color,
    type DevicePresetDto,
    type ImageAdjust,
    type Layer,
    type Layout,
    type ShapeLayer,
} from '@/types/layout';
import Konva from 'konva';
import type { Filter as KonvaFilter } from 'konva/lib/Node';
import { resolveAnchors } from '../anchors';
import { colorToKonvaFill, colorToKonvaStroke, strokeColorAsColor } from '../colors';
import { bezelGeometry } from '../frame';
import { isEffectivelyVisible } from '../hierarchy';
import {
    autoRouteLeader,
    buildEllipsePath,
    buildPathFromNodes,
    buildPolygonPath,
    buildRealFrameOffscreen,
    buildStarPath,
    buildTiltedFrameSilhouette,
    drawIconBadge,
    drawVectorGraphic,
    clipCalloutShape as clipCalloutShapeExport,
    roundedRectPath,
} from '../LayerNode';
import { mediaUrl } from '../media';
import { buildTrapezoidPath, drawTilt, realFrameTiltAllowed } from '../perspective';
import { isComplexScript, localeAwareFontStack, safeLetterSpacing, safeLineHeight } from '../fonts';
import { addWithShadows, resolveShadows } from '../shadows';
import { BLUR_FILTERS, nativeBlurEllipseScene, solidFillValue } from '../blurFilter';
import { makeRichCharRenderFunc } from '../richText';
import { buildShapePath } from '../shapePath';
import {
    applyTextTransform,
    composeFontStyle,
    fitTextSize,
    measureTextNaturalSize,
} from '../textFit';

type RenderOptions = {
    devicePresets?: DevicePresetDto[];
    /** Override the render resolution. Percent anchors re-solve against these
     * dims; px anchors render as authored and will not scale. */
    targetWidth?: number;
    targetHeight?: number;
    /** Locale to render at. Resolves text content per layer via
     *  `resolveLocalizedText(layer.content, locale)`. Omitted = default
     *  locale content. Phase 5 (export per-locale) is the primary caller. */
    locale?: string | null;
    /** Writing direction for `align: 'start' | 'end'` flipping in text
     *  layers. Defaults to 'ltr' if omitted; Phase 5 export wires this
     *  alongside `locale`. */
    direction?: 'ltr' | 'rtl';
};

function rasterUrlFor(layer: Layer): string | undefined {
    if (layer.type === 'image') return mediaUrl(layer.storagePath);
    if (layer.type === 'background') return mediaUrl(layer.image?.storagePath);
    return undefined;
}

/**
 * Cache key for a frame's bound screen image. Kept distinct from the layer-id
 * key used for `image` / `background` so a future feature that lets a frame
 * AND its screen carry the same id doesn't collide.
 */
function frameScreenKey(layerId: string): string {
    return `frame-screen:${layerId}`;
}

/**
 * Cache key for the photorealistic device frame PNG. Independent of the bound
 * screen image so both can coexist for the same layer.
 */
function frameRealKey(layerId: string): string {
    return `frame-real:${layerId}`;
}

/**
 * Strip the trailing `-landscape` suffix off a preset key to recover the
 * portrait/family key used as the manifest directory. Portrait presets pass
 * through unchanged. Mirrors the manifest layout under `public/device-frames/`.
 */
function realFrameFamilyKey(presetKey: string): string {
    return presetKey.endsWith('-landscape')
        ? presetKey.slice(0, -'-landscape'.length)
        : presetKey;
}

/**
 * Resolve the orientation slot for the real-frame manifest. Landscape if the
 * preset key carries the `-landscape` suffix or the form_factor contains
 * `landscape`; portrait otherwise. Mirrors the Phase 2A LayerNode twin.
 */
function realFrameOrientation(preset: DevicePresetDto): 'portrait' | 'landscape' {
    if (preset.key.endsWith('-landscape')) return 'landscape';
    if (preset.form_factor && preset.form_factor.includes('landscape')) return 'landscape';
    return 'portrait';
}

/**
 * Pick the color slug for the real-frame PNG. Prefer the layer's stored slug
 * when it matches an option on the preset; otherwise fall back to the first
 * option. Returns null when the preset has no color options at all (the
 * caller treats that the same as "real frame unavailable").
 */
function resolveRealFrameColor(
    layer: { realFrameColor?: string },
    preset: DevicePresetDto,
): string | null {
    const options = preset.real_frame_color_options ?? [];
    if (options.length === 0) return null;
    if (layer.realFrameColor) {
        const match = options.find((o) => o.slug === layer.realFrameColor);
        if (match) return match.slug;
    }
    return options[0].slug;
}

/**
 * Build the public URL for a frame PNG. Returns undefined when any required
 * piece is missing — the caller treats that the same as "real frame
 * unavailable" and falls back to procedural.
 */
function realFramePngUrl(
    layer: { realFrameColor?: string },
    preset: DevicePresetDto | undefined,
): string | undefined {
    if (!preset) return undefined;
    if (preset.has_real_frame !== true) return undefined;
    if (!preset.real_frame_screen_rect) return undefined;
    const color = resolveRealFrameColor(layer, preset);
    if (!color) return undefined;
    const family = realFrameFamilyKey(preset.key);
    const orientation = realFrameOrientation(preset);
    return `/device-frames/${family}/${color}/${orientation}.png`;
}

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);
}

function resolveImageAdjust(adjust: 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 };
}

function loadImage(url: string): Promise<HTMLImageElement> {
    return new Promise((resolve, reject) => {
        const img = new Image();
        // crossOrigin='anonymous' is only needed for cross-origin sources so
        // the resulting canvas isn't tainted (stage.toCanvas → toBlob would
        // throw SecurityError). For same-origin URLs — local storage's
        // /storage/{path} symlink — setting it can silently fail the load on
        // some dev-server / browser combos that don't echo Access-Control-*
        // for plain static files. The live editor's useImage() never sets it
        // and renders fine; this mirrors that behavior for same-origin.
        try {
            const parsed = new URL(url, window.location.href);
            if (parsed.origin !== window.location.origin) {
                img.crossOrigin = 'anonymous';
            }
        } catch {
            // Unparseable URL (data:, blob:, etc.) — leave crossOrigin unset.
        }
        img.onload = () => resolve(img);
        img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
        img.src = url;
    });
}

async function preloadImages(
    layers: Layer[],
    devicePresets: DevicePresetDto[] | undefined,
): Promise<Map<string, HTMLImageElement>> {
    const images = new Map<string, HTMLImageElement>();
    const tasks: Array<Promise<void>> = [];
    for (const layer of layers) {
        if (!isEffectivelyVisible(layer, layers)) continue;
        if (layer.type === 'image' || (layer.type === 'background' && layer.image)) {
            const url = rasterUrlFor(layer);
            if (!url) continue;
            tasks.push(
                loadImage(url)
                    .then((img) => {
                        images.set(layer.id, img);
                    })
                    .catch(() => {
                        /* leave unset → placeholder */
                    }),
            );
            continue;
        }
        if (layer.type === 'frame') {
            if (layer.screen?.storagePath) {
                const url = mediaUrl(layer.screen.storagePath);
                if (url) {
                    tasks.push(
                        loadImage(url)
                            .then((img) => {
                                images.set(frameScreenKey(layer.id), img);
                            })
                            .catch(() => {
                                /* leave unset → no screen image, screenFill paints */
                            }),
                    );
                }
            }
            // Real-frame PNG preload — only when the layer opts in AND the
            // preset advertises a bundled frame. Tilt fallback is handled at
            // render time (we still preload because the tilt flag isn't
            // known here without recomputing per-layer transforms; cheap
            // either way — the PNG cache hits the same /device-frames URL
            // across every layer that references it).
            if (layer.realFrameEnabled === true) {
                const preset = devicePresets?.find((p) => p.id === layer.devicePresetId);
                const url = realFramePngUrl(layer, preset);
                if (url) {
                    tasks.push(
                        loadImage(url)
                            .then((img) => {
                                images.set(frameRealKey(layer.id), img);
                            })
                            .catch(() => {
                                /* leave unset → fall back to procedural */
                            }),
                    );
                }
            }
        }
    }
    await Promise.all(tasks);
    return images;
}

type CommonShape = {
    id: string;
    name: string;
    x: number;
    y: number;
    rotation: number;
    opacity: number;
    // `normal` is the default — we drop it on the floor in `commonFor` so the
    // attr never ends up on the Konva node with the wrong value. The literal
    // union here is a subset of Konva's internal `globalCompositeOperationType`.
    globalCompositeOperation?: Exclude<BlendMode, 'normal'>;
};

function commonFor(layer: Layer, x: number, y: number): CommonShape {
    const blendMode = layer.style?.blendMode;
    return {
        id: layer.id,
        name: `layer-${layer.id}`,
        x,
        y,
        rotation: layer.rotation ?? 0,
        opacity: layer.style?.opacity ?? 1,
        globalCompositeOperation:
            blendMode && blendMode !== 'normal' ? blendMode : undefined,
    };
}

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 }
            : {};
    // Mirror of LayerNode.tsx::dashConfig — see comment there for the
    // gap-multiplier rationale + clamp range. Both renderers MUST use
    // the same formula so editor preview matches the exported ZIP.
    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 a clipShapeId binding → the target ShapeLayer, if it's valid for
 * masking. Mirrors `buildShapePath`'s accept-set: closed, non-degenerate
 * shapes (not line/arrow, not an open pen path). Returns undefined when the
 * binding is stale or invalid so callers fall back to normal rendering.
 */
function resolveClipShape(
    clipShapeId: string | undefined,
    allLayers: Layer[],
): ShapeLayer | undefined {
    if (!clipShapeId) return undefined;
    const target = allLayers.find((l) => l.id === clipShapeId);
    if (!target || target.type !== 'shape') return undefined;
    if (target.shape === 'line' || target.shape === 'arrow') return undefined;
    if (target.shape === 'path' && target.closed === false) return undefined;
    return target;
}

function addLayerNode(
    konvaLayer: Konva.Container,
    layer: Layer,
    canvas: Canvas,
    images: Map<string, HTMLImageElement>,
    devicePresets: DevicePresetDto[] | undefined,
    allLayers: Layer[],
    locale?: string | null,
    direction: 'ltr' | 'rtl' = 'ltr',
): void {
    // Groups are non-visual — their children render as independent siblings.
    if (layer.type === 'group') return;

    let rect = resolveAnchors(layer.anchors, canvas);
    let common = commonFor(layer, rect.x, rect.y);

    // Image with a valid `clipShapeId` binding renders at its OWN rect +
    // rotation; the shape acts purely as a mask (Pattern B). The clip path is
    // traced in canvas-absolute space via a transform in clipFunc.
    let clipShape: ShapeLayer | undefined;
    let clipShapeRect: { x: number; y: number; width: number; height: number } | undefined;
    let clipShapeRotation = 0;
    if (layer.type === 'image') {
        clipShape = resolveClipShape(layer.clipShapeId, allLayers);
        if (clipShape) {
            clipShapeRect = resolveAnchors(clipShape.anchors, canvas);
            clipShapeRotation = clipShape.rotation ?? 0;
        }
    }

    if (layer.type === 'background') {
        const fill = colorToKonvaFill(layer.fill, rect.width, rect.height);
        const group = new Konva.Group({
            ...common,
            clipX: 0,
            clipY: 0,
            clipWidth: rect.width,
            clipHeight: rect.height,
        });
        group.add(
            new Konva.Rect({
                width: rect.width,
                height: rect.height,
                ...fill,
            }),
        );
        const bgImage = layer.image ? images.get(layer.id) : undefined;
        const blur = layer.image?.blur ?? 0;
        const overlay = layer.image?.overlay;
        const zoom = layer.image?.zoom ?? 1;
        const offX = layer.image?.offsetX ?? 0;
        const offY = layer.image?.offsetY ?? 0;
        const imageOpacity = layer.image?.opacity ?? 1;
        if (bgImage && layer.image!.fit === 'tile') {
            group.add(
                new Konva.Rect({
                    width: rect.width,
                    height: rect.height,
                    fillPatternImage: bgImage,
                    fillPatternRepeat: 'repeat',
                    fillPatternScale: { x: zoom, y: zoom },
                    fillPatternOffset: { x: -offX * rect.width, y: -offY * rect.height },
                    opacity: imageOpacity,
                }),
            );
        } else if (bgImage && (layer.image!.fit === 'cover' || layer.image!.fit === 'contain')) {
            const box = fitBox(bgImage, rect.width, rect.height, layer.image!.fit, zoom, offX, offY);
            const tint = layer.image?.tint;
            const filters: Array<typeof Konva.Filters.Blur> = [];
            const filterAttrs: Record<string, number> = {};
            if (blur > 0) {
                filters.push(Konva.Filters.Blur);
                filterAttrs.blurRadius = blur;
            }
            if (tint && tint.amount > 0) {
                // Recolour visible pixels via the RGBA filter — preserves the
                // image's own alpha (transparent stays clear), so a white abstract
                // becomes an on-brand shade. Mirrors the image-layer tint path.
                const { r, g, b } = parseHexColor(tint.color);
                filters.push(Konva.Filters.RGBA);
                filterAttrs.red = r;
                filterAttrs.green = g;
                filterAttrs.blue = b;
                filterAttrs.alpha = Math.min(1, Math.max(0, tint.amount));
            }
            const img = new Konva.Image({
                image: bgImage,
                x: box.x,
                y: box.y,
                width: box.width,
                height: box.height,
                filters: filters.length ? filters : undefined,
                ...filterAttrs,
                opacity: imageOpacity,
            });
            // Filters only render once the node has been cached into an offscreen
            // raster — without cache() the export looks identical to the input.
            if (filters.length) img.cache();
            group.add(img);
        }
        if (bgImage && overlay && overlay.opacity > 0) {
            group.add(
                new Konva.Rect({
                    width: rect.width,
                    height: rect.height,
                    fill: overlay.color,
                    opacity: overlay.opacity,
                }),
            );
        }
        konvaLayer.add(group);
        return;
    }

    if (layer.type === 'shape') {
        const fill = colorToKonvaFill(layer.style?.fill, rect.width, rect.height);
        const stroke = layer.style?.stroke;
        const shadows = resolveShadows(layer.style);

        // Filled vector graphic (laurel wreath etc.) — mirrors the LayerNode twin.
        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') as CanvasFillRule;
            addWithShadows(konvaLayer, shadows, (shadowProps) =>
                new Konva.Shape({
                    ...common,
                    x: rect.x,
                    y: rect.y,
                    width: rect.width,
                    height: rect.height,
                    ...shadowProps,
                    sceneFunc: (ctx: Konva.Context) => {
                        drawVectorGraphic(
                            ctx as unknown as CanvasRenderingContext2D,
                            rect.width,
                            rect.height,
                            vPaths,
                            vbW,
                            vbH,
                            vFill,
                            vRule,
                        );
                    },
                }),
            );
            return;
        }

        // Icon BADGE — a rounded-rect tile + stroked vector glyph. Bypasses the
        // normal shape geometry; mirrors the LayerNode.tsx twin.
        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;
            addWithShadows(konvaLayer, shadows, (shadowProps) =>
                new Konva.Shape({
                    ...common,
                    x: rect.x,
                    y: rect.y,
                    width: rect.width,
                    height: rect.height,
                    ...shadowProps,
                    sceneFunc: (ctx: Konva.Context) => {
                        drawIconBadge(
                            ctx as unknown as CanvasRenderingContext2D,
                            rect.width,
                            rect.height,
                            iconPaths,
                            tileColor,
                            glyphColor,
                            radius,
                            gsw,
                        );
                    },
                }),
            );
            return;
        }
        const strokeDash = dashConfig(stroke?.dash, stroke?.width ?? 0, stroke?.dashOffset, stroke?.dashGap);
        const blurR = Math.max(0, layer.style?.blur ?? 0);
        // A simple solid-fill ellipse with blur renders through the NATIVE canvas
        // blur (`ctx.filter`) — CSS-identical, rim-free — bypassing the Konva
        // filter group entirely.
        const nativeBlur =
            blurR > 0 &&
            layer.shape === 'ellipse' &&
            (layer.sweepAngle ?? 360) >= 360 &&
            (layer.innerRatio ?? 0) <= 0 &&
            (stroke?.width ?? 0) <= 0 &&
            solidFillValue(layer.style?.fill) !== null;
        // When blurred (and not native), build every shape node into a private
        // group so the Gaussian filter can be cached over the whole shape at once.
        const shapeParent: Konva.Container =
            blurR > 0 && !nativeBlur ? new Konva.Group() : konvaLayer;

        const buildShape = (): void => {
        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) {
                if (nativeBlur) {
                    const solid = solidFillValue(layer.style?.fill) ?? '#000';
                    konvaLayer.add(
                        new Konva.Shape({
                            ...common,
                            x: rect.x,
                            y: rect.y,
                            width: rect.width,
                            height: rect.height,
                            sceneFunc: nativeBlurEllipseScene(
                                rect.width,
                                rect.height,
                                solid,
                                blurR,
                            ) as unknown as (ctx: Konva.Context, node: Konva.Shape) => void,
                        }),
                    );
                    return;
                }
                addWithShadows(shapeParent, shadows, (shadowProps) =>
                    new Konva.Ellipse({
                        ...common,
                        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,
                    }),
                );
                return;
            }

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

            if (usePositionedStroke) {
                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);
                const group = new Konva.Group({
                    ...common,
                    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,
                });
                addWithShadows(group, shadows, (shadowProps) =>
                    new Konva.Shape({
                        width: rect.width,
                        height: rect.height,
                        sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                            buildEllipsePath(
                                ctx as unknown as CanvasRenderingContext2D,
                                cx,
                                cy,
                                rx,
                                ry,
                                startAngle,
                                sweepAngle,
                                innerRatio,
                            );
                            ctx.fillStrokeShape(node);
                        },
                        ...fill,
                        ...shadowProps,
                    }),
                );
                group.add(
                    new Konva.Shape({
                        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,
                    }),
                );
                shapeParent.add(group);
                return;
            }

            addWithShadows(shapeParent, shadows, (shadowProps) =>
                new Konva.Shape({
                    ...common,
                    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: Konva.Context, node: Konva.Shape) => {
                        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,
                }),
            );
            return;
        }

        if (layer.shape === 'line') {
            const lineW = stroke?.width ?? 2;
            const lineDash = dashConfig(stroke?.dash, lineW, stroke?.dashOffset, stroke?.dashGap);
            addWithShadows(shapeParent, shadows, (shadowProps) =>
                new Konva.Line({
                    ...common,
                    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,
                    ...shadowProps,
                }),
            );
            return;
        }

        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';
            addWithShadows(shapeParent, shadows, (shadowProps) =>
                new Konva.Arrow({
                    ...common,
                    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 heads don't support gradients — fall back to the
                    // first stop (or the solid value) for the head paint.
                    fill: strokeSolid,
                    pointerLength: head,
                    pointerWidth: head,
                    pointerAtBeginning: arrowAt === 'start' || arrowAt === 'both',
                    pointerAtEnding: arrowAt === 'end' || arrowAt === 'both',
                    lineCap: arrowDash.lineCap ?? 'butt',
                    lineJoin: stroke?.lineJoin ?? 'round',
                    dash: arrowDash.dash,
                    ...shadowProps,
                }),
            );
            return;
        }

        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);
                const group = new Konva.Group({
                    ...common,
                    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,
                });
                addWithShadows(group, shadows, (shadowProps) =>
                    new Konva.Shape({
                        width: rect.width,
                        height: rect.height,
                        sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                            buildPolygonPath(
                                ctx as unknown as CanvasRenderingContext2D,
                                cx,
                                cy,
                                rx,
                                ry,
                                sides,
                            );
                            ctx.fillStrokeShape(node);
                        },
                        ...fill,
                        ...shadowProps,
                    }),
                );
                group.add(
                    new Konva.Shape({
                        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,
                    }),
                );
                shapeParent.add(group);
                return;
            }

            addWithShadows(shapeParent, shadows, (shadowProps) =>
                new Konva.Shape({
                    ...common,
                    x: rect.x + rect.width / 2,
                    y: rect.y + rect.height / 2,
                    width: rect.width,
                    height: rect.height,
                    sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                        buildPolygonPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rx,
                            ry,
                            sides,
                        );
                        ctx.fillStrokeShape(node);
                    },
                    ...fill,
                    ...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height),
                    strokeWidth: stroke?.width,
                    lineJoin: stroke?.lineJoin,
                    ...strokeDash,
                    ...shadowProps,
                }),
            );
            return;
        }

        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);
                const group = new Konva.Group({
                    ...common,
                    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,
                });
                addWithShadows(group, shadows, (shadowProps) =>
                    new Konva.Shape({
                        width: rect.width,
                        height: rect.height,
                        sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                            buildStarPath(
                                ctx as unknown as CanvasRenderingContext2D,
                                cx,
                                cy,
                                rx,
                                ry,
                                numPoints,
                                innerRatio,
                            );
                            ctx.fillStrokeShape(node);
                        },
                        ...fill,
                        ...shadowProps,
                    }),
                );
                group.add(
                    new Konva.Shape({
                        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,
                    }),
                );
                shapeParent.add(group);
                return;
            }

            addWithShadows(shapeParent, shadows, (shadowProps) =>
                new Konva.Shape({
                    ...common,
                    x: rect.x + rect.width / 2,
                    y: rect.y + rect.height / 2,
                    width: rect.width,
                    height: rect.height,
                    sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                        buildStarPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rx,
                            ry,
                            numPoints,
                            innerRatio,
                        );
                        ctx.fillStrokeShape(node);
                    },
                    ...fill,
                    ...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height),
                    strokeWidth: stroke?.width,
                    lineJoin: stroke?.lineJoin,
                    ...strokeDash,
                    ...shadowProps,
                }),
            );
            return;
        }

        if (layer.shape === 'path') {
            const nodes = layer.nodes ?? [];
            const closed = layer.closed ?? false;
            // Mirror LayerNode: when the panel clears stroke (width → 0 on a
            // closed path), don't fall back to a 2px dark default here either.
            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,
                }));
                const group = new Konva.Group({
                    ...common,
                    width: rect.width,
                    height: rect.height,
                });
                addWithShadows(group, shadows, (shadowProps) =>
                    new Konva.Shape({
                        width: rect.width,
                        height: rect.height,
                        sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                            buildPathFromNodes(
                                ctx as unknown as CanvasRenderingContext2D,
                                nodes,
                                rect.width,
                                rect.height,
                                closed,
                            );
                            ctx.fillStrokeShape(node);
                        },
                        ...fill,
                        ...shadowProps,
                    }),
                );
                group.add(
                    new Konva.Shape({
                        width: rect.width,
                        height: rect.height,
                        sceneFunc: (ctx, node) => {
                            buildPathFromNodes(
                                ctx as unknown as CanvasRenderingContext2D,
                                strokeNodes,
                                rect.width,
                                rect.height,
                                closed,
                            );
                            ctx.fillStrokeShape(node);
                        },
                        fillEnabled: false,
                        ...colorToKonvaStroke(pathStrokeColor, rect.width, rect.height),
                        strokeWidth: pathStrokeW,
                        lineCap: stroke?.lineCap ?? 'round',
                        lineJoin: stroke?.lineJoin ?? 'round',
                        ...strokeDash,
                        listening: false,
                    }),
                );
                shapeParent.add(group);
                return;
            }

            addWithShadows(shapeParent, shadows, (shadowProps) =>
                new Konva.Shape({
                    ...common,
                    width: rect.width,
                    height: rect.height,
                    sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                        buildPathFromNodes(
                            ctx as unknown as CanvasRenderingContext2D,
                            nodes,
                            rect.width,
                            rect.height,
                            closed,
                        );
                        ctx.fillStrokeShape(node);
                    },
                    ...(closed ? fill : { fillEnabled: false }),
                    ...colorToKonvaStroke(pathStrokeColor, rect.width, rect.height),
                    strokeWidth: pathStrokeW,
                    lineCap: stroke?.lineCap ?? 'round',
                    lineJoin: stroke?.lineJoin ?? 'round',
                    ...strokeDash,
                    ...shadowProps,
                }),
            );
            return;
        }

        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);
            const group = new Konva.Group({
                ...common,
                width: rect.width,
                height: rect.height,
            });
            addWithShadows(group, shadows, (shadowProps) =>
                new Konva.Rect({
                    x: 0,
                    y: 0,
                    width: rect.width,
                    height: rect.height,
                    cornerRadius,
                    ...fill,
                    ...shadowProps,
                }),
            );
            group.add(
                new Konva.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,
                }),
            );
            shapeParent.add(group);
            return;
        }

        addWithShadows(shapeParent, shadows, (shadowProps) =>
            new Konva.Rect({
                ...common,
                width: rect.width,
                height: rect.height,
                cornerRadius,
                ...fill,
                ...colorToKonvaStroke(strokeColorAsColor(stroke?.color), rect.width, rect.height),
                strokeWidth: stroke?.width,
                lineJoin: stroke?.lineJoin,
                ...strokeDash,
                ...shadowProps,
            }),
        );
        };

        buildShape();
        if (blurR > 0 && shapeParent !== konvaLayer) {
            const blurGroup = shapeParent as Konva.Group;
            blurGroup.filters(BLUR_FILTERS);
            blurGroup.blurRadius(blurR);
            // Pad the cache box by the blur radius so the soft edge isn't
            // clipped at the shape's tight bounding box.
            const pad = Math.ceil(blurR * 2) + 2;
            const box = blurGroup.getClientRect({ skipTransform: true, relativeTo: blurGroup });
            if (box.width > 0 && box.height > 0) {
                blurGroup.cache({
                    x: box.x - pad,
                    y: box.y - pad,
                    width: box.width + pad * 2,
                    height: box.height + pad * 2,
                });
            }
            konvaLayer.add(blurGroup);
        }
        return;
    }

    if (layer.type === 'text') {
        const raw = resolveLocalizedText(layer.content, locale);
        const text = applyTextTransform(raw, layer.textTransform);
        const align = resolveTextAlign(layer.align, direction);
        const fontStyle = composeFontStyle(layer.fontWeight, layer.fontStyle);
        const textShadows = resolveShadows(layer.style);
        const stroke = layer.style?.stroke;
        const isAuto = layer.fitMode === 'auto';
        // Cap auto-mode width at the canvas — matches LayerNode so the export
        // mirrors what the editor shows when a user types past the canvas edge.
        const natural = isAuto
            ? measureTextNaturalSize(layer, locale, canvas.width)
            : null;
        const boxW = natural ? natural.width : rect.width;
        const boxH = natural ? natural.height : rect.height;
        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, locale);
        const wrapMode =
            natural?.wrapped
                ? 'word'
                : isAuto || layer.fitMode === 'clip'
                  ? 'none'
                  : 'word';
        const textConfig = {
            ...common,
            x: drawX,
            y: drawY,
            width: boxW,
            height: boxH,
            text,
            fontFamily: localeAwareFontStack(layer.fontFamily, locale),
            fontSize: effectiveFontSize,
            fontStyle,
            lineHeight: safeLineHeight(text, layer.lineHeight),
            letterSpacing: safeLetterSpacing(text, layer.letterSpacing),
            align,
            verticalAlign: 'top' as const,
            ...textFill,
            wrap: wrapMode,
            ellipsis: layer.fitMode === 'clip',
            ...colorToKonvaStroke(strokeColorAsColor(stroke?.color), boxW, boxH),
            strokeWidth: stroke?.width,
            fillAfterStrokeEnabled: stroke?.width ? true : undefined,
        };

        // SPIKE — inline rich text: same single Konva.Text + charRenderFunc, so
        // the exported PNG matches the editor exactly (shadows skipped for the
        // rich path in this spike). Pick runs by the rendered text: the default
        // string uses `richRuns`; a locale override's text uses
        // `richRunsByLocale[locale]`. Keeps run indices aligned to `raw`.
        const tileRuns =
            raw === (layer.content.default ?? '')
                ? layer.richRuns
                : locale
                  ? layer.richRunsByLocale?.[locale]
                  : undefined;
        // Rich-text colour/bold uses Konva's per-glyph charRenderFunc, which
        // breaks complex-script shaping. Skip it for those scripts so the export
        // matches the editor — shaped text in the base colour (mirrors LayerNode).
        if (tileRuns && tileRuns.length > 0 && !isComplexScript(text)) {
            const node = new Konva.Text(textConfig);
            node.charRenderFunc(makeRichCharRenderFunc(node, tileRuns, layer.color));
            konvaLayer.add(node);
            return;
        }

        addWithShadows(konvaLayer, textShadows, (shadowProps) =>
            new Konva.Text({ ...textConfig, ...shadowProps }),
        );
        return;
    }

    if (layer.type === 'image') {
        const image = images.get(layer.id);
        if (!image) {
            konvaLayer.add(
                new Konva.Rect({
                    ...common,
                    width: rect.width,
                    height: rect.height,
                    fill: '#E4E4E7',
                    stroke: '#A1A1AA',
                    strokeWidth: 2,
                    dash: [12, 8],
                }),
            );
            return;
        }

        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;
        const skewX = Math.tan(((layer.skewX ?? 0) * Math.PI) / 180);
        const skewY = Math.tan(((layer.skewY ?? 0) * Math.PI) / 180);
        const tiltY = layer.tiltY ?? 0;
        const tiltX = layer.tiltX ?? 0;
        const hasTilt = Math.abs(tiltY) > 0.01 || Math.abs(tiltX) > 0.01;

        const cropAttr = (() => {
            if (!layer.crop) 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));
            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 * image.width,
                y: top * image.height,
                width: wFrac * image.width,
                height: hFrac * image.height,
            };
        })();
        const srcW = cropAttr?.width ?? image.width;
        const srcH = cropAttr?.height ?? image.height;

        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;
        }

        const flipScaleX = flipX ? -1 : 1;
        const flipScaleY = flipY ? -1 : 1;
        const flipOffsetX = flipX ? drawW : 0;
        const flipOffsetY = flipY ? drawH : 0;

        const { filters, attrs: filterAttrs } = resolveImageAdjust(layer.adjust);

        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;

        const hasPanZoom = zoom !== 1 || offX !== 0 || offY !== 0;
        const hasShapeClip = clipShape != null;
        const needsClip =
            imgRadius > 0 ||
            hasPanZoom ||
            fit === 'contain' ||
            flipX ||
            flipY ||
            hasTilt ||
            hasShapeClip;
        const needsGroup =
            needsClip || hasStroke || hasShadow || hasOverlay || filters.length > 0 || hasTilt;

        const buildImageNode = (): Konva.Shape => {
            if (hasTilt) {
                return new Konva.Shape({
                    x: drawX + flipOffsetX,
                    y: drawY + flipOffsetY,
                    width: drawW,
                    height: drawH,
                    scaleX: flipScaleX,
                    scaleY: flipScaleY,
                    listening: false,
                    sceneFunc: (ctx: Konva.Context, shape: Konva.Shape) => {
                        drawTilt(
                            ctx as unknown as CanvasRenderingContext2D,
                            image,
                            shape.width(),
                            shape.height(),
                            tiltX,
                            tiltY,
                            cropAttr,
                        );
                    },
                });
            }
            const node = new Konva.Image({
                image,
                x: drawX + flipOffsetX,
                y: drawY + flipOffsetY,
                width: drawW,
                height: drawH,
                scaleX: flipScaleX,
                scaleY: flipScaleY,
                crop: cropAttr,
                listening: false,
                ...filterAttrs,
            });
            if (filters.length > 0) {
                node.filters(filters);
                node.cache();
            }
            return node;
        };

        if (!needsGroup) {
            const node = new Konva.Image({
                ...common,
                image,
                width: rect.width,
                height: rect.height,
                skewX,
                skewY,
                crop: cropAttr,
                ...filterAttrs,
            });
            if (filters.length > 0) {
                node.filters(filters);
                node.cache();
            }
            konvaLayer.add(node);
            return;
        }

        const group = new Konva.Group({ ...common, skewX, skewY });
        // Shadow: tilt → trapezoid + black fill (opaque silhouette); non-tilt
        // → offscreen rounded image silhouette + shadow projects from it via
        // drawImage, destination-out erases the silhouette. Halo follows BOTH
        // image alpha and rounded corners. Mirrors LayerNode.tsx.
        if (hasShadow && hasTilt) {
            addWithShadows(group, imgShadows, (shadowProps) =>
                new Konva.Shape({
                    width: rect.width,
                    height: rect.height,
                    sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                        buildTrapezoidPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rect.width,
                            rect.height,
                            tiltX,
                            tiltY,
                            imgRadius,
                        );
                        ctx.fillStrokeShape(node);
                    },
                    fill: '#000',
                    ...shadowProps,
                    listening: false,
                }),
            );
        } else if (hasShadow && image) {
            const w = Math.max(1, Math.ceil(rect.width));
            const h = Math.max(1, Math.ceil(rect.height));
            const off = document.createElement('canvas');
            off.width = w;
            off.height = h;
            const offCtx = off.getContext('2d');
            if (offCtx) {
                offCtx.save();
                if (imgRadius > 0) {
                    roundedRectPath(offCtx, 0, 0, rect.width, rect.height, imgRadius);
                    offCtx.clip();
                }
                offCtx.translate(drawX + flipOffsetX, drawY + flipOffsetY);
                offCtx.scale(flipScaleX, flipScaleY);
                if (cropAttr) {
                    offCtx.drawImage(
                        image,
                        cropAttr.x,
                        cropAttr.y,
                        cropAttr.width,
                        cropAttr.height,
                        0,
                        0,
                        drawW,
                        drawH,
                    );
                } else {
                    offCtx.drawImage(image, 0, 0, drawW, drawH);
                }
                offCtx.restore();
                addWithShadows(group, imgShadows, (shadowProps) =>
                    new Konva.Shape({
                        width: rect.width,
                        height: rect.height,
                        ...shadowProps,
                        listening: false,
                        sceneFunc: (ctx: Konva.Context) => {
                            const c = ctx as unknown as CanvasRenderingContext2D;
                            c.drawImage(off, 0, 0);
                            c.shadowColor = 'transparent';
                            c.shadowBlur = 0;
                            c.shadowOffsetX = 0;
                            c.shadowOffsetY = 0;
                            c.globalCompositeOperation = 'destination-out';
                            c.drawImage(off, 0, 0);
                        },
                    }),
                );
            }
        }
        const imgRotRad = ((common.rotation ?? 0) * Math.PI) / 180;
        const shapeRotRad = (clipShapeRotation * Math.PI) / 180;
        const clipGroup = new Konva.Group({
            width: rect.width,
            height: rect.height,
            clipFunc: (ctx) => {
                if (hasShapeClip && clipShape && clipShapeRect) {
                    // Pattern B: trace shape path in canvas-absolute space
                    // via transform. See LayerNode.tsx for the matching math.
                    const c = ctx as unknown as CanvasRenderingContext2D;
                    c.save();
                    if (imgRotRad) c.rotate(-imgRotRad);
                    c.translate(-rect.x, -rect.y);
                    c.translate(clipShapeRect.x, clipShapeRect.y);
                    if (shapeRotRad) c.rotate(shapeRotRad);
                    const ok = buildShapePath(
                        c,
                        clipShape,
                        clipShapeRect.width,
                        clipShapeRect.height,
                    );
                    c.restore();
                    if (ok) return;
                    roundedRectPath(ctx, 0, 0, rect.width, rect.height, 0);
                    return;
                }
                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);
                }
            },
            listening: false,
        });
        clipGroup.add(buildImageNode());
        if (layer.overlay && layer.overlay.opacity > 0) {
            // Tint painted on top of the image, clipped to alpha via source-atop
            // so transparent regions stay clear. Mirrors LayerNode.tsx.
            clipGroup.add(
                new Konva.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.add(clipGroup);
        if (hasStroke && hasTilt) {
            group.add(
                new Konva.Shape({
                    width: rect.width,
                    height: rect.height,
                    sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                        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,
                }),
            );
        } else if (hasStroke) {
            group.add(
                new Konva.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,
                }),
            );
        }
        konvaLayer.add(group);
        return;
    }

    if (layer.type === 'callout') {
        // Mirror of LayerNode's CalloutNode. Render the source layer
        // recursively inside a clipped + zoom-scaled Konva.Group hierarchy.
        // Source must be a frame/image (validated by kit linter + authoring
        // gesture); orphan or wrong-type sources render a dashed placeholder
        // so the export shows something selectable instead of nothing.
        const source = allLayers.find((l) => l.id === layer.sourceLayerId);
        const isValidSource =
            source &&
            source.type !== 'callout' &&
            source.type !== 'group' &&
            source.type !== 'background';

        if (!source || !isValidSource) {
            const group = new Konva.Group({ ...common });
            group.add(
                new Konva.Shape({
                    width: rect.width,
                    height: rect.height,
                    listening: false,
                    stroke: 'rgba(0,0,0,0.3)',
                    strokeWidth: 1,
                    dash: [4, 4],
                    sceneFunc: (ctx, shape) => {
                        const c = ctx as unknown as CanvasRenderingContext2D;
                        clipCalloutShapeExport(c, layer, rect.width, rect.height);
                        ctx.fillStrokeShape(shape);
                    },
                }),
            );
            konvaLayer.add(group);
            return;
        }

        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 = rect.width / sourcePxW;

        const outer = new Konva.Group({ ...common });

        // 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 `addWithShadows` so multi-shadow
        // layouts render fully. Mirrors `CalloutNode` in LayerNode.tsx —
        // keep byte-equivalent.
        const calloutShadows = resolveShadows(layer.style);
        if (calloutShadows.length > 0) {
            addWithShadows(outer, calloutShadows, (shadowProps) =>
                new Konva.Shape({
                    listening: false,
                    fill: '#000',
                    sceneFunc: (ctx: Konva.Context, shape: Konva.Shape) => {
                        const c = ctx as unknown as CanvasRenderingContext2D;
                        clipCalloutShapeExport(c, layer, rect.width, rect.height);
                        ctx.fillStrokeShape(shape);
                    },
                    ...shadowProps,
                }),
            );
        }

        // Leader line (Phase 4). Render BEFORE the clip group so the callout
        // body paints on top of any stroke that grazes the perimeter. Mirrors
        // CalloutNode's leader block in LayerNode.tsx — keep byte-equivalent.
        const leader = layer.leader;
        if (leader?.enabled) {
            const routed =
                leader.from && leader.to
                    ? { from: leader.from, to: leader.to }
                    : autoRouteLeader(sourceFull, rect, layer.shape);
            const localFrom = { x: routed.from.x - rect.x, y: routed.from.y - rect.y };
            const localTo = { x: routed.to.x - rect.x, y: routed.to.y - rect.y };
            const leaderStrokeColor = strokeColorAsColor(leader.stroke);
            const leaderStrokeSolid =
                leaderStrokeColor?.type === 'solid'
                    ? leaderStrokeColor.value
                    : (leaderStrokeColor?.stops?.[0]?.color ?? '#0F172A');
            const leaderWidth = leader.width;
            const leaderStrokeProps = colorToKonvaStroke(
                leaderStrokeColor,
                rect.width,
                rect.height,
            );
            if (leader.style === 'line') {
                outer.add(
                    new Konva.Line({
                        points: [localFrom.x, localFrom.y, localTo.x, localTo.y],
                        ...leaderStrokeProps,
                        strokeWidth: leaderWidth,
                        listening: false,
                    }),
                );
            } else if (leader.style === 'arrow') {
                outer.add(
                    new Konva.Arrow({
                        points: [localFrom.x, localFrom.y, localTo.x, localTo.y],
                        ...leaderStrokeProps,
                        strokeWidth: leaderWidth,
                        fill: leaderStrokeSolid,
                        pointerLength: Math.max(4, leaderWidth * 3),
                        pointerWidth: Math.max(4, leaderWidth * 2.5),
                        listening: false,
                    }),
                );
            } else {
                // curve
                outer.add(
                    new Konva.Shape({
                        ...leaderStrokeProps,
                        strokeWidth: leaderWidth,
                        listening: false,
                        sceneFunc: (ctx, shape) => {
                            const dx = localTo.x - localFrom.x;
                            const dy = localTo.y - localFrom.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 = (localFrom.x + localTo.x) / 2;
                            const cy = (localFrom.y + localTo.y) / 2;
                            ctx.beginPath();
                            ctx.moveTo(localFrom.x, localFrom.y);
                            ctx.quadraticCurveTo(cx + perpX, cy + perpY, localTo.x, localTo.y);
                            ctx.strokeShape(shape);
                        },
                    }),
                );
            }
        }

        const clip = new Konva.Group({
            width: rect.width,
            height: rect.height,
            listening: false,
            clipFunc: (ctx) => {
                const c = ctx as unknown as CanvasRenderingContext2D;
                clipCalloutShapeExport(c, layer, rect.width, rect.height);
            },
        });
        const inner = new Konva.Group({
            x: -sourcePxX * zoom,
            y: -sourcePxY * zoom,
            scaleX: zoom,
            scaleY: zoom,
            listening: false,
        });
        // Recursive — addLayerNode now accepts any Konva.Container, so the
        // source layer's nodes land inside `inner` (which is positioned +
        // scaled to map sourceRect onto the callout's display rect).
        addLayerNode(inner, source, canvas, images, devicePresets, allLayers, locale, direction);
        clip.add(inner);
        outer.add(clip);
        // Shape outline (stroke only — the shadow caster above projects the
        // silhouette even when no stroke is set). Renders AFTER the clipped
        // content so the stroke paints on top of the content edges. Mirrors
        // CalloutNode's stroke block in LayerNode.tsx — keep byte-equivalent.
        const calloutStroke = layer.style?.stroke;
        const calloutStrokeColor = strokeColorAsColor(calloutStroke?.color);
        if ((calloutStroke?.width ?? 0) > 0 && calloutStrokeColor) {
            outer.add(
                new Konva.Shape({
                    listening: false,
                    ...colorToKonvaStroke(calloutStrokeColor, rect.width, rect.height),
                    strokeWidth: calloutStroke!.width,
                    ...dashConfig(
                        calloutStroke!.dash,
                        calloutStroke!.width ?? 0,
                        calloutStroke!.dashOffset,
                        calloutStroke!.dashGap,
                    ),
                    sceneFunc: (ctx, shape) => {
                        const c = ctx as unknown as CanvasRenderingContext2D;
                        clipCalloutShapeExport(c, layer, rect.width, rect.height);
                        ctx.fillStrokeShape(shape);
                    },
                }),
            );
        }
        konvaLayer.add(outer);
        return;
    }

    if (layer.type !== 'frame') return;

    const preset = devicePresets?.find((p) => p.id === layer.devicePresetId);

    // Real-frame (photorealistic PNG) branch. Activates when the layer opts
    // in AND the preset advertises a bundled frame AND any 3D tilt stays
    // inside the ±15°/axis cap (`realFrameTiltAllowed`). Beyond the cap the
    // PNG's hard photoreal edges (camera bumps, speaker grilles) start
    // showing strip-aliasing in the trapezoid sampler — at ≤15° the
    // staircase stays sub-pixel at common export resolutions, perceptually
    // clean. The procedural path doesn't share the cap because its edges are
    // programmatic, already anti-aliased by the trapezoid path-fill pass.
    // The frame PNG must already be loaded into the image cache by
    // `preloadImages`; if the load failed (404, transient network) the
    // cache miss drops us into the procedural path for THIS layer only —
    // other layers in the export keep rendering normally.
    const rfTiltX = layer.tiltX ?? 0;
    const rfTiltY = layer.tiltY ?? 0;
    const rfHasTilt = Math.abs(rfTiltX) > 0.01 || Math.abs(rfTiltY) > 0.01;
    const useRealFrame =
        layer.realFrameEnabled === true &&
        preset?.has_real_frame === true &&
        (!rfHasTilt || realFrameTiltAllowed(rfTiltX, rfTiltY)) &&
        preset?.real_frame_screen_rect != null;
    const frameRealImage = useRealFrame ? images.get(frameRealKey(layer.id)) : undefined;

    if (useRealFrame && frameRealImage && preset) {
        const orientation = realFrameOrientation(preset);
        const screenRectRaw = preset.real_frame_screen_rect![orientation];
        // Scale manifest screen-rect from PNG-native pixel space into the
        // layer's current rect. Source of truth for frame_dims is the loaded
        // image's natural size (mirrors LayerNode.tsx so editor preview and
        // export agree). Falls back to preset.export_w/h only if the image
        // somehow loaded with zero dims — defensive, never hit in practice.
        const frameNaturalW =
            (frameRealImage as HTMLImageElement).naturalWidth || preset.export_w;
        const frameNaturalH =
            (frameRealImage as HTMLImageElement).naturalHeight || preset.export_h;
        const sx = rect.width / Math.max(1, frameNaturalW);
        const sy = rect.height / Math.max(1, frameNaturalH);
        const screenRectScaled = {
            x: screenRectRaw.x * sx,
            y: screenRectRaw.y * sy,
            width: screenRectRaw.width * sx,
            height: screenRectRaw.height * sy,
            radius: screenRectRaw.radius * Math.min(sx, sy),
        };

        // Frame transform: same envelope as the procedural path so cross-
        // factor swaps + flip toggles behave identically.
        const rfSkewX = Math.tan(((layer.skewX ?? 0) * Math.PI) / 180);
        const rfSkewY = Math.tan(((layer.skewY ?? 0) * Math.PI) / 180);
        const rfFlipX = layer.flipX === true;
        const rfFlipY = layer.flipY === true;
        const rfFlipScaleX = rfFlipX ? -1 : 1;
        const rfFlipScaleY = rfFlipY ? -1 : 1;
        const rfFlipOffsetX = rfFlipX ? rect.width : 0;
        const rfFlipOffsetY = rfFlipY ? rect.height : 0;

        // Resolve the bound screen image + fit/crop/adjust — same math as
        // the procedural flat path so swapping renderers produces a
        // pixel-comparable image.
        const rfScreen = layer.screen;
        const rfScreenImage = rfScreen ? images.get(frameScreenKey(layer.id)) : undefined;
        const rfFit = rfScreen?.fit ?? 'cover';
        const rfZoom = Math.max(0.01, rfScreen?.zoom ?? 1);
        const rfOffX = rfScreen?.offsetX ?? 0;
        const rfOffY = rfScreen?.offsetY ?? 0;
        let rfScreenCrop:
            | { x: number; y: number; width: number; height: number }
            | undefined;
        if (rfScreenImage && rfScreen?.crop) {
            const top = Math.max(0, Math.min(0.99, rfScreen.crop.top ?? 0));
            const right = Math.max(0, Math.min(0.99, rfScreen.crop.right ?? 0));
            const bottom = Math.max(0, Math.min(0.99, rfScreen.crop.bottom ?? 0));
            const left = Math.max(0, Math.min(0.99, rfScreen.crop.left ?? 0));
            if (top !== 0 || right !== 0 || bottom !== 0 || left !== 0) {
                const wFrac = Math.max(0.001, 1 - left - right);
                const hFrac = Math.max(0.001, 1 - top - bottom);
                rfScreenCrop = {
                    x: left * rfScreenImage.width,
                    y: top * rfScreenImage.height,
                    width: wFrac * rfScreenImage.width,
                    height: hFrac * rfScreenImage.height,
                };
            }
        }
        const rfSrcW = rfScreenCrop?.width ?? rfScreenImage?.width ?? 0;
        const rfSrcH = rfScreenCrop?.height ?? rfScreenImage?.height ?? 0;
        let rfDrawX = 0;
        let rfDrawY = 0;
        let rfDrawW = screenRectScaled.width;
        let rfDrawH = screenRectScaled.height;
        if (rfScreenImage && (rfFit === 'cover' || rfFit === 'contain')) {
            const box = fitBoxDims(
                rfSrcW,
                rfSrcH,
                screenRectScaled.width,
                screenRectScaled.height,
                rfFit,
                rfZoom,
                rfOffX,
                rfOffY,
            );
            rfDrawX = box.x;
            rfDrawY = box.y;
            rfDrawW = box.width;
            rfDrawH = box.height;
        }
        const rfScreenAdjust = resolveImageAdjust(rfScreen?.adjust);
        const rfScreenFillColor =
            layer.screenFill ?? ({ type: 'solid', value: '#F4F4F5' } as Color);
        const rfScreenFill = colorToKonvaFill(
            rfScreenFillColor,
            screenRectScaled.width,
            screenRectScaled.height,
        );

        const rfGroup = new Konva.Group({ ...common, skewX: rfSkewX, skewY: rfSkewY });

        // Tilted real-frame branch (±15°/axis). Composite the entire device
        // mockup — screen fill + screenshot + status-bar overlay + chassis
        // PNG — into ONE offscreen at the layer rect dims, then feed that
        // bitmap through the same strip-trapezoid sampler the procedural
        // path uses (`drawTilt`). Sampling one composite (vs. strip-tilting
        // each layer separately and stacking) guarantees chassis + screen
        // recede on one continuous plane with no parallax misalignment.
        // Konva image-adjust filters on the screen content are skipped under
        // tilt to match the image-branch contract (Konva filters need a
        // native Image node; the composite is a plain HTMLCanvasElement).
        // Byte-equivalent to the LayerNode.tsx twin via the shared
        // `buildRealFrameOffscreen` helper.
        if (rfHasTilt) {
            // Drop shadow cast from the frame PNG pre-tilted into one offscreen
            // bitmap, so the halo hugs the real rounded silhouette instead of a
            // boxy trapezoid the size of the full layer rect. The caster's
            // pixels are covered by the tilted content pass below; only the
            // offset halo shows. Mirrors the LayerNode.tsx twin.
            const rfFrameShadows = resolveShadows(layer.style);
            const tiltedSilhouette =
                rfFrameShadows.length > 0
                    ? buildTiltedFrameSilhouette(
                          frameRealImage as HTMLImageElement,
                          rect.width,
                          rect.height,
                          rfTiltX,
                          rfTiltY,
                      )
                    : null;
            if (tiltedSilhouette) {
                addWithShadows(rfGroup, rfFrameShadows, (shadowProps) =>
                    new Konva.Image({
                        image: tiltedSilhouette,
                        x: rfFlipOffsetX,
                        y: rfFlipOffsetY,
                        scaleX: rfFlipScaleX,
                        scaleY: rfFlipScaleY,
                        width: rect.width,
                        height: rect.height,
                        ...shadowProps,
                        listening: false,
                    }),
                );
            }
            const realFrameOffscreen = buildRealFrameOffscreen({
                layerWidth: rect.width,
                layerHeight: rect.height,
                frameImage: frameRealImage as HTMLImageElement,
                screenImage: rfScreenImage,
                screenFillColor: rfScreenFillColor,
                screenRect: screenRectScaled,
                screenDraw: {
                    x: rfDrawX,
                    y: rfDrawY,
                    width: rfDrawW,
                    height: rfDrawH,
                },
                screenCrop: rfScreenCrop,
                statusBarOverlay: layer.screen?.statusBarOverlay,
            });
            if (realFrameOffscreen) {
                rfGroup.add(
                    new Konva.Shape({
                        x: rfFlipOffsetX,
                        y: rfFlipOffsetY,
                        width: rect.width,
                        height: rect.height,
                        scaleX: rfFlipScaleX,
                        scaleY: rfFlipScaleY,
                        listening: false,
                        sceneFunc: (ctx: Konva.Context, shape: Konva.Shape) => {
                            drawTilt(
                                ctx as unknown as CanvasRenderingContext2D,
                                realFrameOffscreen,
                                shape.width(),
                                shape.height(),
                                rfTiltX,
                                rfTiltY,
                            );
                        },
                    }),
                );
            }
            konvaLayer.add(rfGroup);
            return;
        }

        // Drop shadow that hugs the REAL device silhouette — cast from the
        // frame PNG's own alpha (a duplicate image behind the content), so the
        // halo follows the photoreal rounded outline + transparent margins
        // instead of a boxy full-rect approximation. The caster's pixels are
        // covered exactly by the top frame PNG; only the offset overhang shows.
        // Mirrors the LayerNode.tsx non-tilt real-frame branch.
        const rfFlatShadows = resolveShadows(layer.style);
        if (rfFlatShadows.length > 0) {
            addWithShadows(rfGroup, rfFlatShadows, (shadowProps) =>
                new Konva.Image({
                    image: frameRealImage,
                    x: rfFlipOffsetX,
                    y: rfFlipOffsetY,
                    scaleX: rfFlipScaleX,
                    scaleY: rfFlipScaleY,
                    width: rect.width,
                    height: rect.height,
                    ...shadowProps,
                    listening: false,
                }),
            );
        }

        const rfInner = new Konva.Group({
            x: rfFlipOffsetX,
            y: rfFlipOffsetY,
            scaleX: rfFlipScaleX,
            scaleY: rfFlipScaleY,
            width: rect.width,
            height: rect.height,
            listening: false,
        });

        // Screen content (fill + optional screenshot), clipped to the
        // manifest's rounded screen_rect. The rect is sized to the device's
        // full canvas dims centered in the PNG, so it covers all interior
        // transparent areas (including dynamic-island carve-outs) without
        // overflowing past the opaque chassis silhouette into the PNG's
        // outer-bleed transparency.
        const rfScreenClip = new Konva.Group({
            x: screenRectScaled.x,
            y: screenRectScaled.y,
            width: screenRectScaled.width,
            height: screenRectScaled.height,
            listening: false,
            clipFunc: (ctx) => {
                roundedRectPath(
                    ctx,
                    0,
                    0,
                    screenRectScaled.width,
                    screenRectScaled.height,
                    screenRectScaled.radius,
                );
            },
        });
        rfScreenClip.add(
            new Konva.Rect({
                width: screenRectScaled.width,
                height: screenRectScaled.height,
                ...rfScreenFill,
                listening: false,
            }),
        );
        if (rfScreenImage) {
            const rfImgNode = new Konva.Image({
                image: rfScreenImage,
                x: rfDrawX,
                y: rfDrawY,
                width: rfDrawW,
                height: rfDrawH,
                crop: rfScreenCrop,
                listening: false,
                ...rfScreenAdjust.attrs,
            });
            if (rfScreenAdjust.filters.length > 0) {
                rfImgNode.filters(rfScreenAdjust.filters);
                rfImgNode.cache();
            }
            rfScreenClip.add(rfImgNode);
        }
        rfInner.add(rfScreenClip);

        // Hide-status-bar overlay (mirrors LayerNode.tsx). Painted INSIDE
        // the screen clip so it doesn't bleed past the rounded mask. Sized
        // as a percentage of the screen's visible height; renders before
        // the chassis PNG so the chassis covers any sub-pixel overflow.
        const sbo = layer.screen?.statusBarOverlay;
        if (sbo?.enabled) {
            const sboHeightPct = Math.max(0, Math.min(0.3, (sbo.heightPct ?? 6) / 100));
            const sboClip = new Konva.Group({
                x: screenRectScaled.x,
                y: screenRectScaled.y,
                width: screenRectScaled.width,
                height: screenRectScaled.height,
                listening: false,
                clipFunc: (ctx) => {
                    roundedRectPath(
                        ctx,
                        0,
                        0,
                        screenRectScaled.width,
                        screenRectScaled.height,
                        screenRectScaled.radius,
                    );
                },
            });
            sboClip.add(
                new Konva.Rect({
                    x: 0,
                    y: 0,
                    width: screenRectScaled.width,
                    height: screenRectScaled.height * sboHeightPct,
                    fill: sbo.color ?? '#000000',
                    listening: false,
                }),
            );
            rfInner.add(sboClip);
        }

        // Top: the photorealistic frame PNG. Transparent screen cutout in the
        // asset itself lets the screen content show through.
        rfInner.add(
            new Konva.Image({
                image: frameRealImage,
                x: 0,
                y: 0,
                width: rect.width,
                height: rect.height,
                listening: false,
            }),
        );

        rfGroup.add(rfInner);
        konvaLayer.add(rfGroup);
        return;
    }

    const bezel = bezelGeometry(rect.width, rect.height, preset);
    const innerRadius = Math.max(0, bezel.chassisRadius - bezel.inset);
    const innerW = Math.max(0, rect.width - bezel.inset * 2);
    const innerH = Math.max(0, rect.height - bezel.inset * 2);
    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,
          }
        : {};
    const screenFillColor = layer.screenFill ?? ({ type: 'solid', value: '#F4F4F5' } as Color);
    const screenFill = colorToKonvaFill(screenFillColor, innerW, innerH);

    // Frame transform: skew on the outer group, flip + tilt apply to bezel +
    // bound screen together so the device mockup transforms as one unit.
    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;
    const flipScaleX = flipX ? -1 : 1;
    const flipScaleY = flipY ? -1 : 1;
    const flipOffsetX = flipX ? rect.width : 0;
    const flipOffsetY = flipY ? rect.height : 0;

    // Bound screen image geometry.
    const screen = layer.screen;
    const screenImage = screen ? images.get(frameScreenKey(layer.id)) : undefined;
    const sFit = screen?.fit ?? 'cover';
    const sZoom = Math.max(0.01, screen?.zoom ?? 1);
    const sOffX = screen?.offsetX ?? 0;
    const sOffY = screen?.offsetY ?? 0;
    let screenCrop: { x: number; y: number; width: number; height: number } | undefined;
    if (screenImage && screen?.crop) {
        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) {
            const wFrac = Math.max(0.001, 1 - left - right);
            const hFrac = Math.max(0.001, 1 - top - bottom);
            screenCrop = {
                x: left * screenImage.width,
                y: top * screenImage.height,
                width: wFrac * screenImage.width,
                height: hFrac * screenImage.height,
            };
        }
    }
    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 screenAdjust = resolveImageAdjust(screen?.adjust);

    const group = new Konva.Group({ ...common, skewX, skewY });

    if (hasTilt) {
        // Rasterize the entire frame visual once and feed it through drawTilt
        // — the strip-trapezoid math then receives the device mockup as one
        // bitmap, so bezel + screen + island all recede together. Filters on
        // the bound screen are skipped under tilt to match the documented
        // image-branch contract (Konva filters need a native Image node).
        const frameShadows = resolveShadows(frameStyle);
        if (frameShadows.length > 0) {
            const primary = frameShadows[frameShadows.length - 1];
            group.add(
                new Konva.Shape({
                    width: rect.width,
                    height: rect.height,
                    sceneFunc: (ctx: Konva.Context, node: Konva.Shape) => {
                        buildTrapezoidPath(
                            ctx as unknown as CanvasRenderingContext2D,
                            0,
                            0,
                            rect.width,
                            rect.height,
                            tiltX,
                            tiltY,
                            bezel.chassisRadius,
                        );
                        ctx.fillStrokeShape(node);
                    },
                    fill: '#000',
                    shadowColor: primary.color,
                    shadowBlur: primary.blur,
                    shadowOffsetX: primary.offsetX,
                    shadowOffsetY: primary.offsetY,
                    shadowOpacity: primary.opacity,
                    listening: false,
                }),
            );
        }
        // Two-pass tilted rendering — chassis silhouette via clean path-fill
        // (no strips → no per-strip color discontinuities on solid chassis),
        // screen content via strip-rendered chassisless offscreen clipped to
        // the same trapezoid. Mirrors LayerNode.tsx FrameNode.
        const chassisColorStr =
            typeof chassisFill.fill === 'string' ? (chassisFill.fill as string) : '#0F172A';

        // Pass 1: chassis path-fill.
        group.add(
            new Konva.Shape({
                x: flipOffsetX,
                y: flipOffsetY,
                width: rect.width,
                height: rect.height,
                scaleX: flipScaleX,
                scaleY: flipScaleY,
                listening: false,
                sceneFunc: (ctx: Konva.Context) => {
                    const c = ctx as unknown as CanvasRenderingContext2D;
                    buildTrapezoidPath(
                        c,
                        0,
                        0,
                        rect.width,
                        rect.height,
                        tiltX,
                        tiltY,
                        bezel.chassisRadius,
                    );
                    c.fillStyle = chassisColorStr;
                    c.fill();
                    const csp = chassisStrokeProps as Record<string, unknown>;
                    const sw = (csp.strokeWidth as number | undefined) ?? 0;
                    if (sw > 0) {
                        c.strokeStyle =
                            typeof csp.stroke === 'string'
                                ? (csp.stroke as string)
                                : '#000000';
                        c.lineWidth = sw;
                        c.stroke();
                    }
                },
            }),
        );

        // Pass 2: screen content (chassisless offscreen, strip-rendered),
        // clipped to the same trapezoid.
        const screenOffscreen = buildFrameScreenOffscreenForExport({
            width: rect.width,
            height: rect.height,
            bezel,
            innerW,
            innerH,
            innerRadius,
            screenFillColor,
            screenImage,
            screenDraw: { x: sDrawX, y: sDrawY, width: sDrawW, height: sDrawH },
            screenCrop,
        });
        if (screenOffscreen) {
            // Inner trapezoid clip at (bezel.inset, bezel.inset, innerW, innerH)
            // — sits inside the chassis trapezoid; the visible bezel is the
            // gap between the two paths and stays uniform thickness. The
            // screen offscreen is now sized to innerW × innerH (no bezel
            // padding) so strip rendering doesn't compress the bezel along
            // with the screen content.
            const tiltClipGroup = new Konva.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,
                    );
                },
            });
            tiltClipGroup.add(
                new Konva.Shape({
                    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: Konva.Context, shape: Konva.Shape) => {
                        drawTilt(
                            ctx as unknown as CanvasRenderingContext2D,
                            screenOffscreen,
                            shape.width(),
                            shape.height(),
                            tiltX,
                            tiltY,
                        );
                    },
                }),
            );
            group.add(tiltClipGroup);
        }

        // Pass 3: uniform bezel stroke overlay. See LayerNode twin.
        group.add(
            new Konva.Shape({
                width: rect.width,
                height: rect.height,
                listening: false,
                sceneFunc: (ctx: Konva.Context) => {
                    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();
                },
            }),
        );
        konvaLayer.add(group);
        return;
    }

    // Flat (no tilt) path — render chassis + screen + island as Konva nodes so
    // gradients/strokes/filters all work natively.
    const inner = new Konva.Group({
        x: flipOffsetX,
        y: flipOffsetY,
        scaleX: flipScaleX,
        scaleY: flipScaleY,
        width: rect.width,
        height: rect.height,
        listening: false,
    });
    addWithShadows(inner, resolveShadows(frameStyle), (shadowProps, isPrimary) =>
        new Konva.Rect({
            width: rect.width,
            height: rect.height,
            ...chassisFill,
            ...chassisStrokeProps,
            cornerRadius: bezel.chassisRadius,
            ...shadowProps,
            listening: isPrimary,
        }),
    );
    inner.add(
        new Konva.Rect({
            x: bezel.inset,
            y: bezel.inset,
            width: innerW,
            height: innerH,
            ...screenFill,
            cornerRadius: innerRadius,
            listening: false,
        }),
    );
    if (screenImage) {
        const screenClip = new Konva.Group({
            x: bezel.inset,
            y: bezel.inset,
            width: innerW,
            height: innerH,
            listening: false,
            clipFunc: (ctx) => {
                roundedRectPath(ctx, 0, 0, innerW, innerH, innerRadius);
            },
        });
        const imgNode = new Konva.Image({
            image: screenImage,
            x: sDrawX,
            y: sDrawY,
            width: sDrawW,
            height: sDrawH,
            crop: screenCrop,
            listening: false,
            ...screenAdjust.attrs,
        });
        if (screenAdjust.filters.length > 0) {
            imgNode.filters(screenAdjust.filters);
            imgNode.cache();
        }
        screenClip.add(imgNode);
        inner.add(screenClip);
    }
    // Hide-status-bar overlay (procedural path mirror). Same data shape as
    // the real-frame branch — `layer.screen.statusBarOverlay`. Painted over
    // the screenshot inside the bezel inner area; the island (next block)
    // sits on top so the hardware cutout stays visible.
    const proceduralSbo = layer.screen?.statusBarOverlay;
    if (proceduralSbo?.enabled) {
        const heightPct = Math.max(0, Math.min(0.3, (proceduralSbo.heightPct ?? 6) / 100));
        inner.add(
            new Konva.Rect({
                x: bezel.inset,
                y: bezel.inset,
                width: innerW,
                height: innerH * heightPct,
                fill: proceduralSbo.color ?? '#000000',
                listening: false,
            }),
        );
    }
    if (bezel.island) {
        inner.add(
            new Konva.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.add(inner);
    konvaLayer.add(group);
}

/**
 * Rasterize JUST the screen content for the export tilt path. See the LayerNode
 * twin `buildFrameScreenOffscreen` for the per-pass rationale.
 */
type FrameScreenOffscreenInputExport = {
    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 path-fill (no strips) so its
 * edge stays crisp. Mirrors `buildFrameScreenOffscreen` in LayerNode.tsx.
 */
function buildFrameScreenOffscreenForExport(
    input: FrameScreenOffscreenInputExport,
): HTMLCanvasElement | null {
    const { bezel, innerW, innerH, innerRadius } = input;
    if (innerW <= 0 || innerH <= 0) return null;
    // Canvas sized to INNER rect, no bezel padding — see LayerNode twin.
    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) {
        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 DownscaleOptions = {
    maxWidth: number;
    /** Output format. JPEG drops alpha but is ~5–10× smaller — right pick for
     * marketing thumbnails on a background. PNG keeps transparency. */
    format?: 'png' | 'jpeg';
    /** Encoder quality 0–1 for JPEG only. Browsers ignore it for PNG. */
    quality?: number;
};

/**
 * Resample a PNG/JPEG blob down to fit within `opts.maxWidth` (height scales
 * proportionally), re-encoding to `opts.format` (default PNG). Used by the
 * kit-export flow so marketing previews — rendered at native canvas resolution
 * for fidelity — ship as compact thumbnails on /templates, /admin/kits, and
 * the projects picker. No-op (returns original) when source ≤ maxWidth AND
 * format already matches. Returns the original blob on any failure so the
 * caller can still proceed.
 */
export async function downscaleImageBlob(
    blob: Blob,
    opts: DownscaleOptions,
): Promise<Blob> {
    const targetFormat = opts.format ?? 'png';
    const mime = targetFormat === 'jpeg' ? 'image/jpeg' : 'image/png';
    const url = URL.createObjectURL(blob);
    try {
        const img = await new Promise<HTMLImageElement>((resolve, reject) => {
            const i = new Image();
            i.onload = () => resolve(i);
            i.onerror = () => reject(new Error('Failed to decode source image for downscale'));
            i.src = url;
        });
        const needsResize = img.naturalWidth > opts.maxWidth;
        const needsReencode = blob.type !== mime;
        if (!needsResize && !needsReencode) return blob;
        const ratio = needsResize ? opts.maxWidth / img.naturalWidth : 1;
        const w = Math.max(1, Math.round(img.naturalWidth * ratio));
        const h = Math.max(1, Math.round(img.naturalHeight * ratio));
        const canvas = document.createElement('canvas');
        canvas.width = w;
        canvas.height = h;
        const ctx = canvas.getContext('2d');
        if (!ctx) return blob;
        // JPEG has no alpha — fill white first so transparent source pixels
        // don't encode as black (the default for unset canvas regions).
        if (targetFormat === 'jpeg') {
            ctx.fillStyle = '#FFFFFF';
            ctx.fillRect(0, 0, w, h);
        }
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.drawImage(img, 0, 0, w, h);
        const out = await new Promise<Blob | null>((resolve) => {
            canvas.toBlob((b) => resolve(b), mime, opts.quality);
        });
        return out ?? blob;
    } catch {
        return blob;
    } finally {
        URL.revokeObjectURL(url);
    }
}

export async function renderScreenToBlob(
    layout: Layout,
    opts?: RenderOptions,
): Promise<Blob> {
    const devicePresets = opts?.devicePresets;

    const targetCanvas: Canvas =
        opts?.targetWidth && opts?.targetHeight
            ? { ...layout.canvas, width: opts.targetWidth, height: opts.targetHeight }
            : layout.canvas;

    const visibleLayers = layout.layers.filter((l) => isEffectivelyVisible(l, layout.layers));
    const images = await preloadImages(visibleLayers, devicePresets);

    const container = document.createElement('div');
    container.style.position = 'fixed';
    container.style.left = '-99999px';
    container.style.top = '-99999px';
    container.style.pointerEvents = 'none';
    document.body.appendChild(container);

    const stage = new Konva.Stage({
        container,
        width: targetCanvas.width,
        height: targetCanvas.height,
    });
    const konvaLayer = new Konva.Layer();
    stage.add(konvaLayer);

    // White canvas backstop — PNG exports default to a transparent stage,
    // which looks broken in app-store listings and most CMS uploaders. A
    // white rect at the bottom of the layer stack guarantees a non-
    // transparent export. Explicit background layers render on top and
    // cover this; semi-transparent backgrounds composite over white
    // instead of over alpha (sane default).
    konvaLayer.add(
        new Konva.Rect({
            x: 0,
            y: 0,
            width: targetCanvas.width,
            height: targetCanvas.height,
            fill: '#ffffff',
            listening: false,
        }),
    );

    try {
        for (const layer of visibleLayers) {
            addLayerNode(
                konvaLayer,
                layer,
                targetCanvas,
                images,
                devicePresets,
                layout.layers,
                opts?.locale ?? null,
                opts?.direction ?? 'ltr',
            );
        }
        konvaLayer.draw();

        const canvas = stage.toCanvas({ pixelRatio: 1 });
        const blob = await new Promise<Blob>((resolve, reject) => {
            canvas.toBlob(
                (b) => (b ? resolve(b) : reject(new Error('canvas.toBlob returned null'))),
                'image/png',
            );
        });
        return blob;
    } finally {
        stage.destroy();
        container.remove();
    }
}
