import { FontFamilyPicker } from '@/Components/FontFamilyPicker';
import { Input, Label, Textarea, Tooltip, useToast } from '@/Components/ui';
import { cn } from '@/lib/cn';
import type {
    BlendMode,
    Canvas,
    Color,
    DevicePresetDto,
    FrameScreen,
    ImageAdjust,
    ImageCrop,
    Layer,
    LineCap,
    LineJoin,
    PathNode,
    Shadow,
    StrokeDash,
    TextTransform,
} from '@/types/layout';
import {
    hasLocaleOverride,
    resolveLocalizedText,
} from '@/types/layout';
import { usePage } from '@inertiajs/react';
import type { LocaleDto, SharedProps } from '@/types/inertia';
import { useActiveLocaleDirection } from './locale';
import {
    recommendedFontsForLocale,
    SCRIPT_LABEL,
    scriptForLocale,
} from './locale-fonts';
import {
    AlignCenter,
    AlignJustify,
    AlignLeft,
    AlignRight,
    ArrowDown,
    ArrowUp,
    Check,
    ChevronDown,
    Circle,
    Image as ImageIcon,
    Italic,
    Link2,
    Link2Off,
    Plus,
    RefreshCw,
    Trash2,
    Upload,
    X,
} from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { resolveAnchors, rectToAnchors } from './anchors';
import { capabilitiesFor, type SectionKey } from './layerCapabilities';
import { strokeColorAsColor } from './colors';
import { createFrameScreen } from './factories';
import { mediaUrl, uploadMedia } from './media';
import { resolveShadows } from './shadows';
import { detectStatusBarColor } from './statusBarDetect';
import { useImage } from './useImage';
import {
    ensureFontLoaded,
    getCustomFonts,
    getFontWeights,
    snapWeightToAvailable,
    subscribeFonts,
    WEIGHT_LABELS,
    type CustomFont,
} from './fonts';
import { useEditor } from './store';

type PropertiesPanelProps = {
    projectId: number;
    /** Read-only — another editor holds the edit lock (P4/Δ21). Inputs render
     *  for context but are non-interactive. */
    readOnly?: boolean;
};

export default function PropertiesPanel({
    projectId,
    readOnly = false,
}: PropertiesPanelProps) {
    const selection = useEditor((s) => s.selection);
    const layer = useEditor((s) =>
        selection.length === 1
            ? s.layout.layers.find((l) => l.id === selection[0])
            : undefined,
    );
    const canvas = useEditor((s) => s.layout.canvas);
    const updateLayer = useEditor((s) => s.updateLayer);
    const previewLayer = useEditor((s) => s.previewLayer);

    return (
        <aside
            className={cn(
                'flex w-56 shrink-0 flex-col border-l border-border bg-card',
                // Locked-out editor: non-interactive (P4/Δ21).
                readOnly && 'pointer-events-none select-none opacity-60',
            )}
            aria-disabled={readOnly || undefined}
        >
            <div className="flex h-9 shrink-0 items-center justify-between gap-2 border-b border-border px-3">
                <h2 className="text-[11px] font-medium text-muted-foreground">
                    Properties
                </h2>
            </div>
            <div className="sleek-scroll flex-1 overflow-y-auto px-3 py-3">
                {!layer ? (
                    <p
                        data-testid="properties-empty"
                        className="text-center text-sm text-muted-foreground/70"
                    >
                        {selection.length > 1
                            ? `${selection.length} layers selected`
                            : 'Select a layer to edit its properties'}
                    </p>
                ) : (
                    <LayerProperties
                        layer={layer}
                        canvas={canvas}
                        projectId={projectId}
                        onUpdate={(recipe) => updateLayer(layer.id, recipe)}
                        onPreview={(recipe) => previewLayer(layer.id, recipe)}
                    />
                )}
            </div>
        </aside>
    );
}

type LayerPropsProps = {
    layer: Layer;
    canvas: Canvas;
    projectId: number;
    onUpdate: (recipe: (draft: Layer) => void) => void;
    onPreview: (recipe: (draft: Layer) => void) => void;
};

function LayerProperties({
    layer,
    canvas,
    projectId,
    onUpdate,
    onPreview,
}: LayerPropsProps) {
    const caps = capabilitiesFor(layer);
    return (
        <div className="space-y-0">
            {layer.type === 'image' && caps.has('clipShape') && (
                <ImageClipCard layer={layer} />
            )}

            <CommonControls
                layer={layer}
                canvas={canvas}
                projectId={projectId}
                onUpdate={onUpdate}
                onPreview={onPreview}
                caps={caps}
            />

            {layer.type === 'text' && (
                <TextControls layer={layer} onUpdate={onUpdate} onPreview={onPreview} caps={caps} />
            )}
            {layer.type === 'shape' && (
                <ShapeControls layer={layer} onUpdate={onUpdate} caps={caps} />
            )}
            {layer.type === 'frame' && (
                <FrameControls
                    layer={layer}
                    projectId={projectId}
                    onUpdate={onUpdate}
                    caps={caps}
                />
            )}
            {layer.type === 'background' && (
                <BackgroundControls
                    layer={layer}
                    projectId={projectId}
                    onUpdate={onUpdate}
                    caps={caps}
                />
            )}
            {layer.type === 'image' && (
                <ImageControls
                    layer={layer}
                    projectId={projectId}
                    onUpdate={onUpdate}
                    caps={caps}
                />
            )}
            {layer.type === 'callout' && (
                <CalloutControls
                    layer={layer}
                    canvas={canvas}
                    onUpdate={onUpdate}
                />
            )}
        </div>
    );
}

/* -------------------------------------------------------------------------- */
/* Image-in-shape clip: bound-image release card + shape-side hint            */
/* -------------------------------------------------------------------------- */

type ShapeLayer = Extract<Layer, { type: 'shape' }>;

// Mirror of store-side validation (bindImageToShape) — line/arrow and open
// paths have no enclosed area, so a clip against them is meaningless even if
// the id technically points at a shape layer.
function isValidClipShape(shape: Layer | undefined): shape is ShapeLayer {
    if (!shape || shape.type !== 'shape') return false;
    if (shape.shape === 'line' || shape.shape === 'arrow') return false;
    if (shape.shape === 'path' && !shape.closed) return false;
    return true;
}

function describeShape(shape: ShapeLayer): string {
    if (shape.name && shape.name.trim().length > 0) return shape.name;
    return `Shape — ${shape.shape}`;
}

function ImageClipCard({ layer }: { layer: Extract<Layer, { type: 'image' }> }) {
    const clipShapeId = layer.clipShapeId;
    const shape = useEditor((s) =>
        clipShapeId ? s.layout.layers.find((l) => l.id === clipShapeId) : undefined,
    );
    const setSelection = useEditor((s) => s.setSelection);
    const unbindImageClip = useEditor((s) => s.unbindImageClip);

    if (!clipShapeId) return null;
    if (!isValidClipShape(shape)) return null;

    return (
        <Section title="Clipped by shape">
            <div
                className="flex items-center justify-between rounded-md border border-border bg-background/40 px-2 py-1.5"
                data-testid="prop-image-clip-row"
            >
                <button
                    type="button"
                    onClick={() => setSelection([shape.id])}
                    className="truncate text-xs text-foreground hover:underline"
                    data-testid="prop-image-clip-label"
                >
                    {describeShape(shape)}
                </button>
                <button
                    type="button"
                    onClick={() => unbindImageClip(layer.id)}
                    className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive"
                    data-testid="prop-image-clip-unbind"
                >
                    Release clip
                </button>
            </div>
        </Section>
    );
}

function ShapeClipHint({ shapeId }: { shapeId: string }) {
    // Select the stable layers reference, then derive ids via useMemo.
    // Selecting `.filter(...).map(...)` inline returns a fresh array every
    // render → Zustand's Object.is equality sees a new ref → infinite loop.
    const layers = useEditor((s) => s.layout.layers);
    const setSelection = useEditor((s) => s.setSelection);
    const clippedIds = useMemo(
        () =>
            layers
                .filter(
                    (l) => l.type === 'image' && l.clipShapeId === shapeId,
                )
                .map((l) => l.id),
        [layers, shapeId],
    );

    if (clippedIds.length === 0) return null;
    const n = clippedIds.length;

    return (
        <button
            type="button"
            onClick={() => setSelection(clippedIds)}
            className="flex w-full items-center justify-between rounded-md border border-border/70 bg-background/40 px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
            data-testid="prop-shape-clip-hint"
        >
            <span>
                Clips {n} layer{n === 1 ? '' : 's'}
            </span>
            <span className="text-muted-foreground/60">Select</span>
        </button>
    );
}

/* -------------------------------------------------------------------------- */
/* Common                                                                     */
/* -------------------------------------------------------------------------- */

function CommonControls({
    layer,
    canvas,
    onUpdate,
    caps,
}: LayerPropsProps & { caps: Set<SectionKey> }) {
    const rect = resolveAnchors(layer.anchors, canvas);
    const isFullBleed = layer.type === 'background';
    const showTransform = !isFullBleed && caps.has('transform');
    const showOpacity = caps.has('opacity');
    const showBlend = caps.has('blend');
    const unit = useGeometryUnit();
    const setUnit = setGeometryUnit;

    const toDisplay = (v: number, axis: 'x' | 'y') =>
        unit === 'percent'
            ? Math.round((v / (axis === 'x' ? canvas.width : canvas.height)) * 1000) / 10
            : Math.round(v);
    const fromDisplay = (v: number, axis: 'x' | 'y') =>
        unit === 'percent' ? (v / 100) * (axis === 'x' ? canvas.width : canvas.height) : v;

    const commitRect = (patch: Partial<{ x: number; y: number; width: number; height: number }>) => {
        onUpdate((draft) => {
            const next = { ...rect, ...patch };
            next.width = Math.max(1, next.width);
            next.height = Math.max(1, next.height);
            draft.anchors = rectToAnchors(next, canvas, layer.anchors);
        });
    };

    // Aspect lock is per-layer and lives in the editor store so the canvas
    // Transformer can honor it too. Presence of layer.id in aspectLocks
    // means this layer's proportions are locked.
    const aspectLockEntry = useEditor((s) => s.aspectLocks[layer.id]);
    const setAspectLock = useEditor((s) => s.setAspectLock);
    const aspectLocked = aspectLockEntry != null;
    const aspectRatio = aspectLockEntry ?? rect.width / Math.max(1, rect.height);
    const toggleAspectLock = () => {
        if (aspectLocked) {
            setAspectLock(layer.id, false);
        } else {
            setAspectLock(layer.id, true, rect.width / Math.max(1, rect.height));
        }
    };

    const commitWidth = (v: number) => {
        const w = Math.max(1, v);
        if (aspectLocked) {
            const h = Math.max(1, Math.round(w / aspectRatio));
            commitRect({ width: w, height: h });
        } else {
            commitRect({ width: w });
        }
    };
    const commitHeight = (v: number) => {
        const h = Math.max(1, v);
        if (aspectLocked) {
            const w = Math.max(1, Math.round(h * aspectRatio));
            commitRect({ width: w, height: h });
        } else {
            commitRect({ height: h });
        }
    };
    const applyRatio = (ratio: number) => {
        // Preserve width, adjust height so the change feels predictable when
        // reshaping a kit hero into a different canvas ratio.
        const h = Math.max(1, Math.round(rect.width / ratio));
        if (aspectLocked) setAspectLock(layer.id, true, ratio);
        commitRect({ height: h });
    };

    const opacity = layer.style?.opacity ?? 1;
    const rotation = layer.rotation ?? 0;
    const blendMode: BlendMode = layer.style?.blendMode ?? 'normal';

    return (
        <Section title="Layer">
            {showTransform && (
                <>
                    <div className="flex items-center justify-end">
                        <div
                            role="group"
                            aria-label="Geometry unit"
                            className="inline-flex overflow-hidden rounded-md border border-border bg-card text-[11px]"
                        >
                            {(['px', 'percent'] as const).map((u) => (
                                <button
                                    key={u}
                                    type="button"
                                    onClick={() => setUnit(u)}
                                    data-testid={`prop-geom-unit-${u}`}
                                    data-active={unit === u ? 'true' : 'false'}
                                    className={cn(
                                        'px-2 py-0.5 font-medium transition-colors',
                                        unit === u
                                            ? 'bg-accent/10 text-accent'
                                            : 'text-muted-foreground hover:text-foreground',
                                    )}
                                >
                                    {u === 'px' ? 'px' : '%'}
                                </button>
                            ))}
                        </div>
                    </div>
                    <div className="grid grid-cols-2 gap-2">
                        <NumberField
                            label="X"
                            value={toDisplay(rect.x, 'x')}
                            step={unit === 'percent' ? 0.1 : 1}
                            onCommit={(v) => commitRect({ x: fromDisplay(v, 'x') })}
                            testId="prop-x"
                        />
                        <NumberField
                            label="Y"
                            value={toDisplay(rect.y, 'y')}
                            step={unit === 'percent' ? 0.1 : 1}
                            onCommit={(v) => commitRect({ y: fromDisplay(v, 'y') })}
                            testId="prop-y"
                        />
                        <NumberField
                            label="W"
                            value={toDisplay(rect.width, 'x')}
                            min={unit === 'percent' ? 0.1 : 1}
                            step={unit === 'percent' ? 0.1 : 1}
                            onCommit={(v) => commitWidth(fromDisplay(v, 'x'))}
                            testId="prop-w"
                        />
                        <NumberField
                            label="H"
                            value={toDisplay(rect.height, 'y')}
                            min={unit === 'percent' ? 0.1 : 1}
                            step={unit === 'percent' ? 0.1 : 1}
                            onCommit={(v) => commitHeight(fromDisplay(v, 'y'))}
                            testId="prop-h"
                        />
                    </div>
                    <div className="flex items-center justify-between gap-2">
                        <button
                            type="button"
                            onClick={toggleAspectLock}
                            data-testid="prop-aspect-lock"
                            data-active={aspectLocked ? 'true' : 'false'}
                            aria-label={aspectLocked ? 'Unlock aspect ratio' : 'Lock aspect ratio'}
                            title={aspectLocked ? 'Unlock aspect ratio' : 'Lock aspect ratio'}
                            className={cn(
                                'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors',
                                aspectLocked
                                    ? 'border-accent bg-accent/10 text-accent'
                                    : 'border-border bg-card text-muted-foreground hover:text-foreground',
                            )}
                        >
                            {aspectLocked ? (
                                <Link2 className="h-3 w-3" strokeWidth={2} />
                            ) : (
                                <Link2Off className="h-3 w-3" strokeWidth={2} />
                            )}
                            Lock
                        </button>
                        <div className="flex gap-1">
                            {(
                                [
                                    { label: '1:1', ratio: 1 },
                                    { label: '4:3', ratio: 4 / 3 },
                                    { label: '16:9', ratio: 16 / 9 },
                                ] as const
                            ).map((r) => (
                                <button
                                    key={r.label}
                                    type="button"
                                    onClick={() => applyRatio(r.ratio)}
                                    data-testid={`prop-ratio-${r.label.replace(':', '-')}`}
                                    className="rounded-md border border-border bg-card px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
                                >
                                    {r.label}
                                </button>
                            ))}
                        </div>
                    </div>
                    <SliderField
                        label="Rotation"
                        value={((Math.round(rotation) % 360) + 360) % 360}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                draft.rotation = ((v % 360) + 360) % 360;
                            })
                        }
                        testId="prop-rotation"
                    />
                </>
            )}
            {showOpacity && (
                <SliderField
                    label="Opacity"
                    value={Math.round(opacity * 100)}
                    min={0}
                    max={100}
                    onChange={(v) =>
                        onUpdate((draft) => {
                            draft.style = { ...draft.style, opacity: v / 100 };
                        })
                    }
                    testId="prop-opacity"
                />
            )}
            {showBlend && (
                <BlendModeSelect
                    value={blendMode}
                    onChange={(v) =>
                        onUpdate((draft) => {
                            draft.style = {
                                ...draft.style,
                                blendMode: v === 'normal' ? undefined : v,
                            };
                        })
                    }
                />
            )}
        </Section>
    );
}

const BLEND_MODE_GROUPS: Array<{ label: string; options: BlendMode[] }> = [
    { label: 'Normal', options: ['normal'] },
    { label: 'Darken', options: ['darken', 'multiply', 'color-burn'] },
    { label: 'Lighten', options: ['lighten', 'screen', 'color-dodge'] },
    { label: 'Contrast', options: ['overlay', 'soft-light', 'hard-light'] },
    { label: 'Inversion', options: ['difference', 'exclusion'] },
    { label: 'Component', options: ['hue', 'saturation', 'color', 'luminosity'] },
];

function blendModeLabel(mode: BlendMode): string {
    return mode.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

function BlendModeSelect({
    value,
    onChange,
}: {
    value: BlendMode;
    onChange: (v: BlendMode) => void;
}) {
    return (
        <div className="space-y-1.5">
            <Label>Blend mode</Label>
            <select
                value={value}
                onChange={(e) => onChange(e.target.value as BlendMode)}
                data-testid="prop-blend-mode"
                className="h-8 w-full rounded-md border border-border bg-card px-2 text-xs text-foreground focus:border-accent focus:outline-none"
            >
                {BLEND_MODE_GROUPS.map((group) => (
                    <optgroup key={group.label} label={group.label}>
                        {group.options.map((opt) => (
                            <option key={opt} value={opt}>
                                {blendModeLabel(opt)}
                            </option>
                        ))}
                    </optgroup>
                ))}
            </select>
        </div>
    );
}

/* -------------------------------------------------------------------------- */
/* Text                                                                       */
/* -------------------------------------------------------------------------- */

type TextProps = {
    layer: Extract<Layer, { type: 'text' }>;
    onUpdate: LayerPropsProps['onUpdate'];
    onPreview: LayerPropsProps['onPreview'];
    caps: Set<SectionKey>;
};

function TextControls({ layer, onUpdate, onPreview, caps }: TextProps) {
    const contentId = useId();
    const activeLocale = useEditor((s) => s.activeLocale);
    const defaultLocale = useEditor((s) => s.defaultLocale);
    const localeDirection = useActiveLocaleDirection();
    const isLocaleOverride = activeLocale !== defaultLocale;
    // Script-aware font shortlist. Latin locales return an empty list →
    // the picker falls back to its default ordering. Non-Latin scripts
    // surface a "Recommended for {Script}" group at the top of the
    // dropdown so authors find a glyph-complete font without trial-and-error.
    const localeScript = useMemo(() => scriptForLocale(activeLocale), [activeLocale]);
    const recommendedFonts = useMemo(
        () => recommendedFontsForLocale(activeLocale),
        [activeLocale],
    );
    const scriptLabel = SCRIPT_LABEL[localeScript];
    const hasOverride = isLocaleOverride && hasLocaleOverride(layer.content, activeLocale);
    const textValue = resolveLocalizedText(layer.content, activeLocale);

    const writeContent = (value: string) => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            if (isLocaleOverride) {
                // Always persist the typed value (including empty string)
                // as the locale override. Empty string is a legitimate
                // override — "this layer is blank for Arabic". The Reset
                // to default button is the only path back to inheritance,
                // so backspacing to clear doesn't snap the default text
                // back into the textarea.
                draft.content = { ...draft.content, [activeLocale]: value };
            } else {
                draft.content = { ...draft.content, default: value };
            }
        });
    };

    const resetLocaleOverride = () => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            const next = { ...draft.content };
            delete (next as Record<string, string>)[activeLocale];
            draft.content = next;
        });
    };
    const currentWeight =
        typeof layer.fontWeight === 'number'
            ? layer.fontWeight
            : layer.fontWeight === 'bold'
              ? 700
              : 400;

    const commitFamily = (family: string) => {
        void ensureFontLoaded(family);
        const snapped = snapWeightToAvailable(family, currentWeight);
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            draft.fontFamily = family;
            draft.fontWeight = snapped;
        });
    };

    const previewFamily = (family: string) => {
        void ensureFontLoaded(family);
        const snapped = snapWeightToAvailable(family, currentWeight);
        onPreview((draft) => {
            if (draft.type !== 'text') return;
            draft.fontFamily = family;
            draft.fontWeight = snapped;
        });
    };

    const commitWeight = (weight: number) => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            draft.fontWeight = weight;
        });
    };

    const italic = layer.fontStyle === 'italic';
    const toggleItalic = () => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            draft.fontStyle = italic ? 'normal' : 'italic';
        });
    };

    const alignValue: 'left' | 'center' | 'right' | 'justify' =
        layer.align === 'start'
            ? 'left'
            : layer.align === 'end'
              ? 'right'
              : layer.align ?? 'left';
    const commitAlign = (v: 'left' | 'center' | 'right' | 'justify') => {
        const mapped = v === 'left' ? 'start' : v === 'right' ? 'end' : v;
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            draft.align = mapped;
        });
    };

    const transform: TextTransform = layer.textTransform ?? 'none';
    const commitTransform = (v: TextTransform) => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            draft.textTransform = v;
        });
    };

    // Linked is the default: text content syncs across form-factor variants
    // unless the author explicitly opts out via the toggle. Mirrors the
    // server-side default in `LayoutResolver::applyFactorEditV4`.
    const linked = layer.linkedContent !== false;
    const toggleLink = () => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            draft.linkedContent = !linked;
        });
    };

    return (
        <Section title="Text">
            {(() => {
                // The current locale's OWN runs (default → richRuns, override →
                // richRunsByLocale[locale]).
                const ownRuns = isLocaleOverride
                    ? layer.richRunsByLocale?.[activeLocale]
                    : layer.richRuns;
                if (!ownRuns || ownRuns.length === 0) return null;
                return (
                    <div
                        className="flex items-center justify-between gap-2 rounded-md border border-border bg-background/40 px-2 py-1.5 text-[11px] text-muted-foreground"
                        data-testid="prop-text-rich-state"
                    >
                        <span>Per-word styling applied{isLocaleOverride ? ` (${activeLocale})` : ''}</span>
                        <button
                            type="button"
                            className="rounded border border-border px-1.5 py-0.5 font-medium text-foreground hover:bg-muted"
                            onClick={() =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'text') return;
                                    if (isLocaleOverride) {
                                        const map = { ...(draft.richRunsByLocale ?? {}) };
                                        delete map[activeLocale];
                                        draft.richRunsByLocale = Object.keys(map).length
                                            ? map
                                            : undefined;
                                    } else {
                                        draft.richRuns = undefined;
                                    }
                                })
                            }
                        >
                            Clear
                        </button>
                    </div>
                );
            })()}
            <div className="space-y-1.5">
                <div className="flex items-center justify-between gap-2">
                    <Label htmlFor={contentId}>
                        Content
                        {isLocaleOverride && (
                            <span
                                className="ml-1.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground"
                                data-testid="prop-text-locale-tag"
                            >
                                {activeLocale}
                            </span>
                        )}
                    </Label>
                    <Tooltip
                        side="bottom-end"
                        multiline
                        label={
                            linked
                                ? 'Linked across factors — editing this text on any device updates it everywhere. Click to make it per-factor so each device can have its own copy.'
                                : 'Per-factor — edits to this text only apply to the current device. Click to link the text across all device factors.'
                        }
                    >
                        <button
                            type="button"
                            onClick={toggleLink}
                            data-testid="prop-text-link"
                            data-active={linked ? 'true' : 'false'}
                            aria-pressed={linked}
                            aria-label={linked ? 'Unlink content' : 'Link content'}
                            className={cn(
                                'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-medium transition-colors',
                                linked
                                    ? 'border-accent bg-accent/10 text-accent'
                                    : 'border-border bg-card text-muted-foreground hover:text-foreground',
                            )}
                        >
                            {linked ? (
                                <Link2 className="h-3 w-3" strokeWidth={2} />
                            ) : (
                                <Link2Off className="h-3 w-3" strokeWidth={2} />
                            )}
                            {linked ? 'Linked' : 'Per-variant'}
                        </button>
                    </Tooltip>
                </div>
                {isLocaleOverride && !hasOverride && (
                    <div
                        className="flex items-center gap-1.5 text-[10px] text-muted-foreground"
                        data-testid="prop-text-locale-inherited"
                    >
                        <span>Inherited from default ({defaultLocale}).</span>
                        <span className="text-muted-foreground/70">
                            Type to override for {activeLocale}.
                        </span>
                    </div>
                )}
                {isLocaleOverride && hasOverride && (
                    <div className="flex items-center justify-between gap-2 text-[10px]">
                        <span className="text-accent" data-testid="prop-text-locale-override">
                            Override for {activeLocale}.
                        </span>
                        <button
                            type="button"
                            onClick={resetLocaleOverride}
                            data-testid="prop-text-locale-reset"
                            className="rounded-md border border-border bg-card px-1.5 py-0.5 font-medium text-muted-foreground transition-colors hover:text-foreground"
                        >
                            Reset to default
                        </button>
                    </div>
                )}
                <Textarea
                    id={contentId}
                    dir={localeDirection}
                    value={textValue}
                    onChange={(e) => writeContent(e.target.value)}
                    rows={3}
                    data-testid="prop-text-content"
                />
            </div>
            {/* Fill sits right under the content textarea — "what does it
                say" → "what color is it" is the natural authoring rhythm.
                Stroke + shadow stay at the bottom of the panel (decorative
                / less-common effects). */}
            {caps.has('fill') && <TextFillControls layer={layer} onUpdate={onUpdate} />}
            <div className="space-y-1">
                <Label>Font family</Label>
                <FontFamilyPicker
                    value={layer.fontFamily}
                    onCommit={commitFamily}
                    onPreview={previewFamily}
                    testIdPrefix="prop-text-fontfamily"
                    requiredScript={localeScript}
                    recommended={
                        recommendedFonts.length > 0
                            ? { label: scriptLabel, families: recommendedFonts }
                            : undefined
                    }
                />
            </div>
            <div className="space-y-1">
                <Label>Style</Label>
                <div className="flex gap-2">
                    <div className="flex-1">
                        <FontWeightPicker
                            family={layer.fontFamily}
                            value={currentWeight}
                            onCommit={commitWeight}
                        />
                    </div>
                    <button
                        type="button"
                        onClick={toggleItalic}
                        data-testid="prop-text-italic"
                        data-active={italic ? 'true' : 'false'}
                        aria-pressed={italic}
                        aria-label="Italic"
                        title="Italic"
                        className={cn(
                            'flex h-9 w-9 shrink-0 items-center justify-center rounded-md border transition-colors',
                            italic
                                ? 'border-accent bg-accent/10 text-accent'
                                : 'border-border bg-background text-muted-foreground hover:text-foreground',
                        )}
                    >
                        <Italic className="h-3.5 w-3.5" strokeWidth={2} />
                    </button>
                </div>
            </div>
            {/* Slider + inline number input — drag for visual sizing, type
                for precise values. Range 8-400 covers everything from caption
                text to hero headlines at native device resolution; user can
                still type above 400 in the inline input and it'll clamp. */}
            <SliderField
                label="Font size"
                value={Math.round(layer.fontSize ?? 0)}
                min={8}
                max={400}
                suffix="px"
                onChange={(v) =>
                    onUpdate((draft) => {
                        if (draft.type !== 'text') return;
                        if (Number.isFinite(v)) draft.fontSize = v;
                    })
                }
                testId="prop-text-fontsize"
            />
            <div className="space-y-1.5" data-testid="prop-text-fit-mode-row">
                <Label>Resize</Label>
                <Segmented
                    value={
                        layer.fitMode === 'auto'
                            ? 'auto'
                            : layer.fitMode === 'shrink'
                              ? 'shrink'
                              : 'fixed'
                    }
                    preserveCase
                    options={[
                        { value: 'auto', label: 'Auto' },
                        { value: 'fixed', label: 'Fixed' },
                        { value: 'shrink', label: 'Fit' },
                    ]}
                    onChange={(v) =>
                        onUpdate((draft) => {
                            if (draft.type !== 'text') return;
                            draft.fitMode = v;
                        })
                    }
                    testIdPrefix="prop-text-fit-mode"
                />
                {layer.fitMode === 'shrink' && (
                    <p
                        className="text-[10px] leading-snug text-muted-foreground/80"
                        data-testid="prop-text-fit-mode-help"
                    >
                        Auto-fits between the min/max range to the layer's box on
                        each device.
                    </p>
                )}
            </div>
            {layer.fitMode === 'shrink' && (
                <div
                    className="grid grid-cols-2 gap-2"
                    data-testid="prop-text-fontsize-bounds"
                >
                    <NumberField
                        label="Min size"
                        value={layer.minFontSize}
                        min={1}
                        onCommit={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'text') return;
                                if (!Number.isFinite(v) || v < 1) return;
                                draft.minFontSize = v;
                                // Keep ordering sane — bump max if user
                                // typed a min above the current max.
                                if (draft.maxFontSize < v) draft.maxFontSize = v;
                            })
                        }
                        testId="prop-text-min-fontsize"
                    />
                    <NumberField
                        label="Max size"
                        value={layer.maxFontSize}
                        min={1}
                        onCommit={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'text') return;
                                if (!Number.isFinite(v) || v < 1) return;
                                draft.maxFontSize = v;
                                if (draft.minFontSize > v) draft.minFontSize = v;
                            })
                        }
                        testId="prop-text-max-fontsize"
                    />
                </div>
            )}
            {/* Letter spacing: pixel offset between glyphs. Negative tightens
                (serif display headlines often run -2 to -4); positive opens
                up (small-caps + uppercase mono usually +1 to +6). The inline
                number input still accepts values outside the visible slider
                range (clamped on commit) so extreme tracking stays reachable. */}
            <SliderField
                label="Letter spacing"
                value={Math.round(layer.letterSpacing ?? 0)}
                min={-10}
                max={30}
                suffix="px"
                onChange={(v) =>
                    onUpdate((draft) => {
                        if (draft.type !== 'text') return;
                        draft.letterSpacing = v;
                    })
                }
                testId="prop-text-letterspacing"
            />
            {/* Line height: multiplier (1.0 = single-line, 1.5 = 1.5×). Step
                0.05 gives fine control while keeping the slider feel responsive.
                Display range 0.8-2.5 covers headlines (tight 1.0-1.1) through
                body copy (1.4-1.6) through generous editorial (~1.8). */}
            <SliderField
                label="Line height"
                value={layer.lineHeight ?? 1.2}
                min={0.8}
                max={2.5}
                step={0.05}
                suffix="×"
                onChange={(v) =>
                    onUpdate((draft) => {
                        if (draft.type !== 'text') return;
                        draft.lineHeight = v;
                    })
                }
                testId="prop-text-lineheight"
            />
            <div className="space-y-1.5" data-testid="prop-text-align-row">
                <Label>Alignment</Label>
                <IconSegmented
                    value={alignValue}
                    onChange={commitAlign}
                    options={[
                        { value: 'left', icon: AlignLeft, label: 'Align left' },
                        { value: 'center', icon: AlignCenter, label: 'Align center' },
                        { value: 'right', icon: AlignRight, label: 'Align right' },
                        { value: 'justify', icon: AlignJustify, label: 'Justify' },
                    ]}
                    testIdPrefix="prop-text-align"
                />
            </div>
            <div className="space-y-1.5" data-testid="prop-text-transform-row">
                <Label>Transform</Label>
                <Segmented
                    value={transform}
                    preserveCase
                    options={[
                        { value: 'none', label: 'Aa' },
                        { value: 'uppercase', label: 'AG' },
                        { value: 'lowercase', label: 'ag' },
                        { value: 'capitalize', label: 'Ag' },
                    ]}
                    onChange={(v) => commitTransform(v as TextTransform)}
                    testIdPrefix="prop-text-transform"
                />
            </div>
            {caps.has('stroke') && <TextStrokeControls layer={layer} onUpdate={onUpdate} />}
            {caps.has('shadow') && <ShadowControls layer={layer} onUpdate={onUpdate} />}
        </Section>
    );
}

const EMPTY_CUSTOM_FONTS: ReadonlyArray<CustomFont> = [];

type FontWeightPickerProps = {
    family: string;
    value: number;
    onCommit: (weight: number) => void;
};

function FontWeightPicker({ family, value, onCommit }: FontWeightPickerProps) {
    useSyncExternalStore(subscribeFonts, getCustomFonts, () => EMPTY_CUSTOM_FONTS);
    const weights = useMemo(() => getFontWeights(family), [family]);
    const [open, setOpen] = useState(false);
    const rootRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (!open) return;
        const onDown = (e: MouseEvent) => {
            if (rootRef.current?.contains(e.target as Node)) return;
            setOpen(false);
        };
        document.addEventListener('mousedown', onDown);
        return () => document.removeEventListener('mousedown', onDown);
    }, [open]);

    const label = WEIGHT_LABELS[value] ?? String(value);

    return (
        <div ref={rootRef} className="relative">
            <button
                type="button"
                onClick={() => setOpen((o) => !o)}
                data-testid="prop-text-fontweight"
                className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-background px-2 text-xs text-foreground hover:border-accent/60 focus:border-accent focus:outline-none"
                style={{ fontFamily: family, fontWeight: value }}
            >
                <span className="truncate">
                    {label}
                    <span className="ml-1.5 font-mono text-[10px] text-muted-foreground">
                        {value}
                    </span>
                </span>
                <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
            </button>
            {open && (
                <div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-64 overflow-y-auto rounded-md border border-border bg-card shadow-lg">
                    {weights.map((w) => (
                        <button
                            key={w}
                            type="button"
                            onClick={() => {
                                onCommit(w);
                                setOpen(false);
                            }}
                            data-testid={`prop-text-fontweight-option-${w}`}
                            style={{ fontFamily: family, fontWeight: w }}
                            className={cn(
                                'flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left text-xs',
                                w === value
                                    ? 'bg-accent/10 text-accent'
                                    : 'text-foreground hover:bg-muted',
                            )}
                        >
                            <span className="truncate">
                                {WEIGHT_LABELS[w] ?? String(w)}
                            </span>
                            <span className="font-mono text-[10px] text-muted-foreground">
                                {w}
                            </span>
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}

type ShadowControlsProps = {
    layer: Layer;
    onUpdate: LayerPropsProps['onUpdate'];
    /** Test-id prefix so text + shape shadows stay addressable independently. */
    testIdPrefix?: string;
};

type TextSubControlsProps = {
    layer: Extract<Layer, { type: 'text' }>;
    onUpdate: LayerPropsProps['onUpdate'];
};

function TextFillControls({ layer, onUpdate }: TextSubControlsProps) {
    const fill: Color =
        layer.fill ?? { type: 'solid', value: layer.color };

    const setFill = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            if (next.type === 'solid') {
                draft.color = next.value;
                draft.fill = undefined;
            } else {
                draft.fill = next;
                // Keep `color` synced to a sensible solid fallback (first
                // stop) so renderers that only read `color` stay reasonable.
                const nextStops = Array.isArray(next.stops) ? next.stops : [];
                draft.color = nextStops[0]?.color ?? draft.color;
            }
        });

    return (
        <div className="space-y-2">
            <div className="space-y-1.5" data-testid="prop-text-fill-type-row">
                <Label>Fill</Label>
                <Segmented
                    value={fill.type}
                    options={[
                        { value: 'solid', label: 'Solid' },
                        { value: 'linear', label: 'Linear' },
                        { value: 'radial', label: 'Radial' },
                    ]}
                    onChange={(nextType) => {
                        if (nextType === fill.type) return;
                        if (nextType === 'solid') {
                            setFill({
                                type: 'solid',
                                value: stopsFromFill(fill)[0]?.color ?? '#0052FF',
                            });
                        } else if (nextType === 'linear') {
                            setFill({
                                type: 'linear',
                                angle: fill.type === 'linear' ? fill.angle : 135,
                                stops: stopsFromFill(fill),
                            });
                        } else {
                            setFill({
                                type: 'radial',
                                cx: fill.type === 'radial' ? fill.cx : 0.5,
                                cy: fill.type === 'radial' ? fill.cy : 0.5,
                                radius: fill.type === 'radial' ? fill.radius : 0.5,
                                stops: stopsFromFill(fill),
                            });
                        }
                    }}
                    testIdPrefix="prop-text-fill-type"
                />
            </div>

            {fill.type === 'solid' && (
                <ColorRow
                    label="Color"
                    value={fill.value}
                    onChange={(v) => setFill({ type: 'solid', value: v })}
                    testId="prop-text-color"
                />
            )}

            {fill.type === 'linear' && (
                <>
                    <SliderField
                        label="Angle"
                        value={Math.round(fill.angle)}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            setFill({
                                ...fill,
                                angle: ((v % 360) + 360) % 360,
                            })
                        }
                        testId="prop-text-fill-angle"
                    />
                    <StopsEditor
                        stops={stopsFromFill(fill)}
                        onChange={(stops) => setFill({ ...fill, stops })}
                    />
                </>
            )}

            {fill.type === 'radial' && (
                <>
                    <SliderField
                        label="Center X"
                        value={Math.round(fill.cx * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, cx: v / 100 })}
                        testId="prop-text-fill-cx"
                    />
                    <SliderField
                        label="Center Y"
                        value={Math.round(fill.cy * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, cy: v / 100 })}
                        testId="prop-text-fill-cy"
                    />
                    <SliderField
                        label="Radius"
                        value={Math.round(fill.radius * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, radius: v / 100 })}
                        testId="prop-text-fill-radius"
                    />
                    <StopsEditor
                        stops={stopsFromFill(fill)}
                        onChange={(stops) => setFill({ ...fill, stops })}
                    />
                </>
            )}
        </div>
    );
}

function TextStrokeControls({ layer, onUpdate }: TextSubControlsProps) {
    const stroke = layer.style?.stroke;
    const enabled = !!stroke && stroke.width > 0;
    const defaultWidth = 2;
    const defaultColor = '#000000';

    const toggle = () => {
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            if (draft.style?.stroke) {
                draft.style = { ...draft.style, stroke: undefined };
            } else {
                draft.style = {
                    ...draft.style,
                    stroke: { color: defaultColor, width: defaultWidth },
                };
            }
        });
    };

    const setStrokeColor = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            const existing = draft.style?.stroke;
            draft.style = {
                ...draft.style,
                stroke: {
                    ...existing,
                    color: next,
                    width: existing?.width ?? defaultWidth,
                },
            };
        });

    const setStrokeWidth = (width: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'text') return;
            const existing = draft.style?.stroke;
            draft.style = {
                ...draft.style,
                stroke: {
                    ...existing,
                    color: existing?.color ?? defaultColor,
                    width,
                },
            };
        });

    const colorValue: Color =
        strokeColorAsColor(stroke?.color) ?? { type: 'solid', value: defaultColor };

    return (
        <div className="space-y-2">
            <div className="flex items-center justify-between">
                <Label>Stroke</Label>
                <SwitchToggle
                    checked={enabled}
                    onChange={toggle}
                    testId="prop-text-stroke-toggle"
                />
            </div>
            {enabled && stroke && (
                <div
                    className="space-y-2 rounded-md border border-border bg-background/40 p-2"
                    data-testid="prop-text-stroke-color"
                >
                    <StrokePaintPicker
                        value={colorValue}
                        onChange={setStrokeColor}
                        defaultColor={defaultColor}
                        label="Color"
                        testIdPrefix="prop-text-stroke"
                    />
                    {/* Slider + inline number — 0-20 covers text-outline
                        weights from hairline (1) through display-heavy
                        (10+). Larger values still typeable in the inline
                        input and clamp to the slider visual range. */}
                    <SliderField
                        label="Width"
                        value={stroke.width}
                        min={0}
                        max={20}
                        step={0.5}
                        suffix="px"
                        onChange={(v) => setStrokeWidth(Math.max(0, v))}
                        testId="prop-text-stroke-width"
                    />
                </div>
            )}
        </div>
    );
}

const MAX_SHADOWS = 4;

const NEW_SHADOW: Shadow = {
    color: '#000000',
    blur: 12,
    offsetX: 0,
    offsetY: 4,
    opacity: 0.25,
};

function ShadowControls({
    layer,
    onUpdate,
    testIdPrefix = 'prop-text-shadow',
}: ShadowControlsProps) {
    const shadows = resolveShadows(layer.style);

    const writeShadows = (next: Shadow[]) =>
        onUpdate((draft) => {
            if (
                draft.type !== 'shape' &&
                draft.type !== 'text' &&
                draft.type !== 'image' &&
                draft.type !== 'background' &&
                draft.type !== 'frame' &&
                draft.type !== 'callout'
            ) {
                return;
            }
            draft.style = {
                ...draft.style,
                shadows: next.length > 0 ? next : undefined,
                // Clear the legacy single field on first write so we don't
                // double-render it on top of the new list.
                shadow: undefined,
            };
        });

    const add = () => {
        if (shadows.length >= MAX_SHADOWS) return;
        writeShadows([...shadows, { ...NEW_SHADOW }]);
    };

    const removeAt = (i: number) =>
        writeShadows(shadows.filter((_, idx) => idx !== i));

    const updateAt = (i: number, patch: Partial<Shadow>) =>
        writeShadows(
            shadows.map((s, idx) => (idx === i ? { ...s, ...patch } : s)),
        );

    const moveUp = (i: number) => {
        if (i === 0) return;
        const next = [...shadows];
        [next[i - 1], next[i]] = [next[i], next[i - 1]];
        writeShadows(next);
    };

    const moveDown = (i: number) => {
        if (i === shadows.length - 1) return;
        const next = [...shadows];
        [next[i + 1], next[i]] = [next[i], next[i + 1]];
        writeShadows(next);
    };

    const atMax = shadows.length >= MAX_SHADOWS;

    return (
        <div className="space-y-2">
            <div className="flex items-center justify-between">
                <div className="flex items-center gap-1.5">
                    <Label className="mb-0">Shadows</Label>
                    <span className="text-[11px] font-medium text-muted-foreground/70">
                        ({shadows.length})
                    </span>
                </div>
                {shadows.length > 0 && (
                    <button
                        type="button"
                        onClick={add}
                        disabled={atMax}
                        data-testid={`${testIdPrefix}-add`}
                        aria-label="Add shadow"
                        title={atMax ? `Max ${MAX_SHADOWS} shadows` : 'Add shadow'}
                        className={cn(
                            'flex h-6 w-6 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors',
                            atMax
                                ? 'cursor-not-allowed opacity-40'
                                : 'hover:text-foreground hover:bg-muted',
                        )}
                    >
                        <Plus className="h-3.5 w-3.5" strokeWidth={2} />
                    </button>
                )}
            </div>

            {shadows.length === 0 ? (
                <button
                    type="button"
                    onClick={add}
                    data-testid={`${testIdPrefix}-add`}
                    className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border bg-background/40 px-2 py-2 text-[11px] font-medium text-muted-foreground transition-colors hover:border-accent/50 hover:text-foreground"
                >
                    <Plus className="h-3.5 w-3.5" strokeWidth={2} />
                    <span>Add shadow</span>
                </button>
            ) : (
                <div className="divide-y divide-border/60 rounded-md border border-border/60">
                    {shadows.map((shadow, i) => (
                        <div
                            key={i}
                            className="space-y-2 px-2 py-2"
                            data-testid={`${testIdPrefix}-entry-${i}`}
                        >
                            <div className="flex items-center justify-between">
                                <span className="text-[11px] font-medium text-muted-foreground/80">
                                    Shadow {i + 1}
                                </span>
                                <div className="flex items-center gap-0.5">
                                    <button
                                        type="button"
                                        onClick={() => moveUp(i)}
                                        disabled={i === 0}
                                        data-testid={`prop-shadow-move-up-${i}`}
                                        aria-label="Move shadow up"
                                        title="Move up"
                                        className={cn(
                                            'flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors',
                                            i === 0
                                                ? 'cursor-not-allowed opacity-30'
                                                : 'hover:bg-muted hover:text-foreground',
                                        )}
                                    >
                                        <ArrowUp className="h-3 w-3" strokeWidth={2} />
                                    </button>
                                    <button
                                        type="button"
                                        onClick={() => moveDown(i)}
                                        disabled={i === shadows.length - 1}
                                        data-testid={`prop-shadow-move-down-${i}`}
                                        aria-label="Move shadow down"
                                        title="Move down"
                                        className={cn(
                                            'flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors',
                                            i === shadows.length - 1
                                                ? 'cursor-not-allowed opacity-30'
                                                : 'hover:bg-muted hover:text-foreground',
                                        )}
                                    >
                                        <ArrowDown className="h-3 w-3" strokeWidth={2} />
                                    </button>
                                    <button
                                        type="button"
                                        onClick={() => removeAt(i)}
                                        data-testid={`prop-shadow-remove-${i}`}
                                        aria-label="Remove shadow"
                                        title="Remove shadow"
                                        className="flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
                                    >
                                        <X className="h-3 w-3" strokeWidth={2} />
                                    </button>
                                </div>
                            </div>
                            {/* X / Y offsets: drag for visual placement,
                                negative offsets shift the shadow up/left
                                (rare but useful for inset-style looks).
                                Range ±60 covers everything from subtle
                                drop-shadows (0–8) through dramatic offset
                                shadows (20+); larger values still typeable
                                in the inline input and clamp on commit. */}
                            <div className="grid grid-cols-2 gap-2">
                                <SliderField
                                    label="X"
                                    value={Math.round(shadow.offsetX ?? 0)}
                                    min={-60}
                                    max={60}
                                    suffix="px"
                                    onChange={(v) => updateAt(i, { offsetX: v })}
                                    testId={`prop-shadow-offset-x-${i}`}
                                />
                                <SliderField
                                    label="Y"
                                    value={Math.round(shadow.offsetY ?? 0)}
                                    min={-60}
                                    max={60}
                                    suffix="px"
                                    onChange={(v) => updateAt(i, { offsetY: v })}
                                    testId={`prop-shadow-offset-y-${i}`}
                                />
                            </div>
                            {/* Blur: 0 = hard-edged (color-only fill area),
                                40 = soft cloud blur. Sliding from 0 → 40
                                is the dominant authoring gesture for
                                getting the right "feel" of a shadow. */}
                            <SliderField
                                label="Blur"
                                value={Math.round(shadow.blur ?? 0)}
                                min={0}
                                max={80}
                                suffix="px"
                                onChange={(v) =>
                                    updateAt(i, { blur: Math.max(0, v) })
                                }
                                testId={`prop-shadow-blur-${i}`}
                            />
                            <ColorRow
                                label="Color"
                                value={shadow.color}
                                onChange={(v) => updateAt(i, { color: v })}
                                testId={`prop-shadow-color-${i}`}
                            />
                            <SliderField
                                label="Opacity"
                                value={Math.round((shadow.opacity ?? 1) * 100)}
                                min={0}
                                max={100}
                                onChange={(v) =>
                                    updateAt(i, { opacity: v / 100 })
                                }
                                testId={`prop-shadow-opacity-${i}`}
                            />
                        </div>
                    ))}
                </div>
            )}
        </div>
    );
}

/* -------------------------------------------------------------------------- */
/* Shape                                                                      */
/* -------------------------------------------------------------------------- */

type ShapeProps = {
    layer: Extract<Layer, { type: 'shape' }>;
    onUpdate: LayerPropsProps['onUpdate'];
    caps?: Set<SectionKey>;
};

function ShapeControls({ layer, onUpdate, caps }: ShapeProps) {
    // Open paths are stroke-driven same as line/arrow — no fill, min stroke = 1.
    const isStrokeOnly =
        layer.shape === 'line' ||
        layer.shape === 'arrow' ||
        (layer.shape === 'path' && !layer.closed);
    // A filled vector graphic (laurel badge etc.): only fill + shadow make sense.
    // Corner radius / borders / blur don't apply to a path silhouette.
    const isVector = Array.isArray(layer.vectorPaths) && layer.vectorPaths.length > 0;
    // line/arrow are stroke-driven — a 0-stroke line is invisible — so the stroke
    // color field is always shown regardless of stroke width being > 0.
    const showStrokeColor = isStrokeOnly || (layer.style?.stroke?.width ?? 0) > 0;

    return (
        <Section title="Shape">
            <ShapeClipHint shapeId={layer.id} />
            {!isStrokeOnly && <ShapeFillControls layer={layer} onUpdate={onUpdate} />}
            {layer.shape === 'rect' && !isVector && (
                <div className="space-y-2">
                    <div className="flex items-center justify-between">
                        <Label>Corner radius</Label>
                        <div className="flex items-center gap-2">
                            <span className="text-[11px] font-medium text-muted-foreground/80">
                                Per corner
                            </span>
                            <SwitchToggle
                                checked={Boolean(layer.style?.radii)}
                                onChange={() =>
                                    onUpdate((draft) => {
                                        if (draft.type !== 'shape') return;
                                        const r = draft.style?.radius ?? 0;
                                        if (draft.style?.radii) {
                                            draft.style = {
                                                ...draft.style,
                                                radii: undefined,
                                            };
                                        } else {
                                            draft.style = {
                                                ...draft.style,
                                                radii: {
                                                    topLeft: r,
                                                    topRight: r,
                                                    bottomRight: r,
                                                    bottomLeft: r,
                                                },
                                            };
                                        }
                                    })
                                }
                                testId="prop-shape-radii-toggle"
                            />
                        </div>
                    </div>
                    {!layer.style?.radii ? (
                        <SliderField
                            label="Corner radius"
                            value={Math.round(layer.style?.radius ?? 0)}
                            min={0}
                            max={200}
                            suffix="px"
                            onChange={(v) =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'shape') return;
                                    draft.style = { ...draft.style, radius: v };
                                })
                            }
                            testId="prop-shape-radius"
                        />
                    ) : (
                        <CornerRadiiGrid layer={layer} onUpdate={onUpdate} />
                    )}
                </div>
            )}
            {layer.shape === 'polygon' && (
                <SliderField
                    label="Sides"
                    value={Math.max(3, Math.min(12, layer.sides ?? 6))}
                    min={3}
                    max={12}
                    suffix=""
                    onChange={(v) =>
                        onUpdate((draft) => {
                            if (draft.type !== 'shape') return;
                            draft.sides = v;
                        })
                    }
                    testId="prop-shape-sides"
                />
            )}
            {layer.shape === 'star' && (
                <>
                    <SliderField
                        label="Points"
                        value={Math.max(3, Math.min(12, layer.starPoints ?? 5))}
                        min={3}
                        max={12}
                        suffix=""
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'shape') return;
                                draft.starPoints = v;
                            })
                        }
                        testId="prop-shape-star-points"
                    />
                    <SliderField
                        label="Inner radius"
                        value={Math.round(
                            Math.max(0.1, Math.min(0.9, layer.innerRatio ?? 0.4)) * 100,
                        )}
                        min={10}
                        max={90}
                        suffix="%"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'shape') return;
                                draft.innerRatio = v / 100;
                            })
                        }
                        testId="prop-shape-inner-ratio"
                    />
                </>
            )}
            {layer.shape === 'arrow' && (
                <>
                    <SliderField
                        label="Head size"
                        value={Math.round(Math.max(0.5, Math.min(3, layer.arrowHead ?? 1)) * 100)}
                        min={50}
                        max={300}
                        suffix="%"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'shape') return;
                                draft.arrowHead = v / 100;
                            })
                        }
                        testId="prop-shape-arrow-head"
                    />
                    <div className="space-y-1.5">
                        <Label>Arrow at</Label>
                        <Segmented<'start' | 'end' | 'both'>
                            value={layer.arrowAt ?? 'end'}
                            options={[
                                { value: 'start', label: 'Start' },
                                { value: 'end', label: 'End' },
                                { value: 'both', label: 'Both' },
                            ]}
                            onChange={(v) =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'shape') return;
                                    draft.arrowAt = v === 'end' ? undefined : v;
                                })
                            }
                            testIdPrefix="prop-shape-arrow-at"
                        />
                    </div>
                </>
            )}
            {layer.shape === 'ellipse' && (
                <>
                    {/* Order: Sweep → Start angle → Inner radius. Sweep is
                        the "how much arc to show" knob, the dominant first
                        edit; start angle rotates that arc; inner radius
                        carves a donut hole. */}
                    <SliderField
                        label="Sweep"
                        value={Math.round(Math.max(0, Math.min(360, layer.sweepAngle ?? 360)))}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'shape') return;
                                draft.sweepAngle = v;
                            })
                        }
                        testId="prop-shape-ellipse-sweep"
                    />
                    <SliderField
                        label="Start angle"
                        value={Math.round(((layer.startAngle ?? 0) % 360 + 360) % 360)}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'shape') return;
                                draft.startAngle = v % 360;
                            })
                        }
                        testId="prop-shape-ellipse-start-angle"
                    />
                    <SliderField
                        label="Inner radius"
                        value={Math.round(
                            Math.max(0, Math.min(0.95, layer.innerRatio ?? 0)) * 100,
                        )}
                        min={0}
                        max={95}
                        suffix="%"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'shape') return;
                                draft.innerRatio = v / 100;
                            })
                        }
                        testId="prop-shape-ellipse-inner-ratio"
                    />
                </>
            )}
            {layer.shape === 'path' && <PathControls layer={layer} onUpdate={onUpdate} />}
            {/* Borders — mirrors the text-tool Stroke pattern: a label-+-toggle
                header with the configurable sub-controls in a bordered card
                below. For stroke-only shapes (line, arrow, open path) the
                stroke is intrinsic — no toggle, card always shown. Hidden for
                filled vector graphics (a path silhouette has no border). */}
            {!isVector && (
            <div className="space-y-2">
                <div className="flex items-center justify-between">
                    <Label>Borders</Label>
                    {!isStrokeOnly && (
                        <SwitchToggle
                            checked={(layer.style?.stroke?.width ?? 0) > 0}
                            onChange={() =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'shape') return;
                                    if ((draft.style?.stroke?.width ?? 0) > 0) {
                                        draft.style = { ...draft.style, stroke: undefined };
                                    } else {
                                        draft.style = {
                                            ...draft.style,
                                            stroke: {
                                                ...(draft.style?.stroke ?? {}),
                                                color: draft.style?.stroke?.color ?? '#0F172A',
                                                width: 1,
                                            },
                                        };
                                    }
                                })
                            }
                            testId="prop-shape-borders-toggle"
                        />
                    )}
                </div>
                {showStrokeColor && (
                    <div
                        className="space-y-2 rounded-md border border-border bg-background/40 p-2"
                        data-testid="prop-shape-borders-card"
                    >
                        <ShapeStrokePaint
                            layer={layer}
                            onUpdate={onUpdate}
                            defaultColor="#0F172A"
                            defaultWidth={isStrokeOnly ? 6 : 1}
                        />
                        <SliderField
                            label="Width"
                            value={layer.style?.stroke?.width ?? 0}
                            min={isStrokeOnly ? 1 : 0}
                            max={40}
                            step={0.5}
                            suffix="px"
                            onChange={(v) =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'shape') return;
                                    const existing = draft.style?.stroke;
                                    const nextWidth = isStrokeOnly ? Math.max(1, v) : v;
                                    if (nextWidth <= 0) {
                                        draft.style = { ...draft.style, stroke: undefined };
                                    } else {
                                        draft.style = {
                                            ...draft.style,
                                            stroke: {
                                                ...existing,
                                                color: existing?.color ?? '#0F172A',
                                                width: nextWidth,
                                            },
                                        };
                                    }
                                })
                            }
                            testId="prop-shape-stroke-width"
                        />
                        {(layer.shape === 'rect' ||
                            layer.shape === 'ellipse' ||
                            layer.shape === 'polygon' ||
                            layer.shape === 'star' ||
                            (layer.shape === 'path' && layer.closed)) &&
                            (layer.style?.stroke?.width ?? 0) > 0 && (
                            <div className="space-y-1.5">
                                <Label>Position</Label>
                                <Segmented
                                    value={layer.style?.stroke?.position ?? 'center'}
                                    options={[
                                        { value: 'center', label: 'Center' },
                                        { value: 'inside', label: 'Inside' },
                                        { value: 'outside', label: 'Outside' },
                                    ]}
                                    onChange={(v) =>
                                        onUpdate((draft) => {
                                            if (draft.type !== 'shape') return;
                                            const existing = draft.style?.stroke;
                                            if (!existing) return;
                                            draft.style = {
                                                ...draft.style,
                                                stroke: {
                                                    ...existing,
                                                    position: v === 'center' ? undefined : v,
                                                },
                                            };
                                        })
                                    }
                                    testIdPrefix="prop-shape-stroke-position"
                                />
                            </div>
                        )}
                        <div className="space-y-1.5">
                            <Label>Style</Label>
                            <Segmented<StrokeDash>
                                value={layer.style?.stroke?.dash ?? 'solid'}
                                options={[
                                    { value: 'solid', label: 'Solid' },
                                    { value: 'dashed', label: 'Dashed' },
                                    { value: 'dotted', label: 'Dotted' },
                                ]}
                                onChange={(v) =>
                                    onUpdate((draft) => {
                                        if (draft.type !== 'shape') return;
                                        const existing = draft.style?.stroke;
                                        // line/arrow can have a dash preset even with
                                        // no explicit stroke object saved yet — mint one
                                        // with the same defaults as the stroke-width editor.
                                        const base = existing ?? {
                                            color: '#0F172A',
                                            width: isStrokeOnly ? 6 : 1,
                                        };
                                        draft.style = {
                                            ...draft.style,
                                            stroke: {
                                                ...base,
                                                dash: v === 'solid' ? undefined : v,
                                            },
                                        };
                                    })
                                }
                                testIdPrefix="prop-shape-stroke-dash"
                            />
                        </div>
                        {/* Dash gap — multiplier on stroke width controlling
                            the SPACE between dashes/dots. Default 2 (≈ the
                            historical Konva pattern); lower tightens, higher
                            spreads. The old Dash-offset control (which only
                            shifted the pattern phase) was retired here in
                            favor of this more useful "more / less space"
                            slider. dashOffset stays in the data model for
                            back-compat with existing layouts. */}
                        {layer.style?.stroke?.dash && layer.style.stroke.dash !== 'solid' && (
                            <SliderField
                                label="Dash gap"
                                value={layer.style.stroke.dashGap ?? 2}
                                min={0.5}
                                max={8}
                                step={0.25}
                                suffix="×"
                                onChange={(v) =>
                                    onUpdate((draft) => {
                                        if (draft.type !== 'shape') return;
                                        const existing = draft.style?.stroke;
                                        if (!existing) return;
                                        draft.style = {
                                            ...draft.style,
                                            stroke: {
                                                ...existing,
                                                // Persist undefined when at
                                                // the default so JSON stays
                                                // minimal for unchanged layers.
                                                dashGap: Math.abs(v - 2) < 0.01 ? undefined : v,
                                            },
                                        };
                                    })
                                }
                                testId="prop-shape-stroke-dash-gap"
                            />
                        )}
                        {(layer.shape === 'line' ||
                            layer.shape === 'arrow' ||
                            (layer.shape === 'path' && !layer.closed)) &&
                            (layer.style?.stroke?.dash ?? 'solid') !== 'dotted' && (
                            <div className="space-y-1.5">
                                <Label>Line cap</Label>
                                <Segmented<LineCap>
                                    value={layer.style?.stroke?.lineCap ?? 'round'}
                                    options={[
                                        { value: 'butt', label: 'Butt' },
                                        { value: 'round', label: 'Round' },
                                        { value: 'square', label: 'Square' },
                                    ]}
                                    onChange={(v) =>
                                        onUpdate((draft) => {
                                            if (draft.type !== 'shape') return;
                                            const existing = draft.style?.stroke ?? {
                                                color: '#0F172A',
                                                width: isStrokeOnly ? 6 : 1,
                                            };
                                            draft.style = {
                                                ...draft.style,
                                                stroke: {
                                                    ...existing,
                                                    lineCap: v === 'round' ? undefined : v,
                                                },
                                            };
                                        })
                                    }
                                    testIdPrefix="prop-shape-line-cap"
                                />
                            </div>
                        )}
                        {/* Line join removed from the visible UI — `miter`
                            (default) is fine for the dominant use cases
                            (rectangles, ellipses, gentle polygons at
                            small-to-medium stroke widths). The renderer
                            still honors stored `stroke.lineJoin` on any
                            layer that has it from earlier sessions; we
                            just don't surface a control to set new
                            values. Bring back if customer feedback says
                            star-shape spikes are a problem. */}
                    </div>
                )}
            </div>
            )}
            {/* Gaussian blur — softens the whole shape into a cloud. Drives the
                soft decorative blobs used by the AI generator; 0 = crisp. Hidden
                for filled vector graphics (a path silhouette isn't blurred). */}
            {!isVector && (
            <SliderField
                label="Blur"
                value={Math.round(layer.style?.blur ?? 0)}
                min={0}
                max={200}
                suffix="px"
                onChange={(v) =>
                    onUpdate((draft) => {
                        if (draft.type !== 'shape') return;
                        draft.style = {
                            ...draft.style,
                            blur: v > 0 ? v : undefined,
                        };
                    })
                }
                testId="prop-shape-blur"
            />
            )}
            {(!caps || caps.has('shadow')) && (
                <ShadowControls
                    layer={layer}
                    onUpdate={onUpdate}
                    testIdPrefix="prop-shape-shadow"
                />
            )}
        </Section>
    );
}

/* -------------------------------------------------------------------------- */
/* Frame                                                                      */
/* -------------------------------------------------------------------------- */

type FrameProps = {
    layer: Extract<Layer, { type: 'frame' }>;
    projectId?: number;
    onUpdate: LayerPropsProps['onUpdate'];
    caps?: Set<SectionKey>;
};

const FRAME_BEZEL_DEFAULT = '#0F172A';
const FRAME_SCREEN_DEFAULT = '#F4F4F5';

function FrameControls({
    layer,
    projectId,
    onUpdate,
    caps,
}: FrameProps) {
    const showFill = !caps || caps.has('fill');
    const showScreenFill = !caps || caps.has('screenFill');
    const showStroke = !caps || caps.has('stroke');
    const showShadow = !caps || caps.has('shadow');
    const showFrameScreen = !caps || caps.has('frameScreen');
    const showFrameTransform = !caps || caps.has('frameTransform');
    const devicePresets = useEditor((s) => s.devicePresets);
    const devicePreset = devicePresets.find((p) => p.id === layer.devicePresetId);
    const colorOptions = devicePreset?.real_frame_color_options ?? [];
    const hasRealFrame = devicePreset?.has_real_frame === true;
    const useReal = layer.realFrameEnabled === true && hasRealFrame;
    const activeColorSlug =
        layer.realFrameColor ?? (colorOptions[0]?.slug ?? null);

    const bezel: Color =
        layer.style?.fill ?? { type: 'solid', value: FRAME_BEZEL_DEFAULT };
    const setBezel = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            draft.style = { ...draft.style, fill: next };
        });

    const screen: Color =
        layer.screenFill ?? { type: 'solid', value: FRAME_SCREEN_DEFAULT };
    const setScreen = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            draft.screenFill = next;
        });

    const strokeWidth = layer.style?.stroke?.width ?? 0;
    const showStrokeColor = strokeWidth > 0;

    return (
        <Section title="Frame">
            {hasRealFrame && (
                <div className="space-y-1.5">
                    <Label>Style</Label>
                    <Segmented
                        value={useReal ? 'real' : 'stylized'}
                        options={[
                            { value: 'real', label: 'Real' },
                            { value: 'stylized', label: 'Stylized' },
                        ]}
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'frame') return;
                                if (v === 'real') {
                                    draft.realFrameEnabled = true;
                                    if (!draft.realFrameColor && colorOptions[0]) {
                                        draft.realFrameColor = colorOptions[0].slug;
                                    }
                                    // Clamp tilt to ±15° on enable — real-frame
                                    // renderer caps at 15°; keeping the raw value
                                    // would diverge from the visually-pinned
                                    // slider after the range tightens.
                                    const tx = draft.tiltX ?? 0;
                                    const ty = draft.tiltY ?? 0;
                                    if (tx > 15) draft.tiltX = 15;
                                    else if (tx < -15) draft.tiltX = -15;
                                    if (ty > 15) draft.tiltY = 15;
                                    else if (ty < -15) draft.tiltY = -15;
                                } else {
                                    draft.realFrameEnabled = false;
                                    delete draft.realFrameColor;
                                }
                            })
                        }
                        testIdPrefix="prop-frame-style"
                    />
                </div>
            )}
            {useReal && colorOptions.length > 0 && (
                <div className="space-y-1.5">
                    <Label>Color</Label>
                    <div className="grid grid-cols-5 gap-1.5">
                        {colorOptions.map((opt) => {
                            const isActive = activeColorSlug === opt.slug;
                            return (
                                <Tooltip key={opt.slug} side="bottom" label={opt.label}>
                                    <button
                                        type="button"
                                        onClick={() =>
                                            onUpdate((draft) => {
                                                if (draft.type !== 'frame') return;
                                                draft.realFrameColor = opt.slug;
                                            })
                                        }
                                        data-testid={`prop-frame-color-${opt.slug}`}
                                        aria-pressed={isActive}
                                        aria-label={opt.label}
                                        className={cn(
                                            'h-7 w-7 rounded-md transition-colors',
                                            isActive
                                                ? 'border-2 border-accent ring-1 ring-accent/30'
                                                : 'border border-border hover:border-foreground/40',
                                        )}
                                        style={{ backgroundColor: opt.swatch }}
                                    />
                                </Tooltip>
                            );
                        })}
                    </div>
                </div>
            )}
            {showFill && !useReal && (
                <div className="space-y-1.5">
                    <StrokePaintPicker
                        value={bezel}
                        onChange={setBezel}
                        defaultColor={FRAME_BEZEL_DEFAULT}
                        label="Bezel"
                        testIdPrefix="prop-frame-bezel"
                    />
                </div>
            )}
            {showScreenFill && !useReal && (
                <div className="space-y-1.5">
                    <StrokePaintPicker
                        value={screen}
                        onChange={setScreen}
                        defaultColor={FRAME_SCREEN_DEFAULT}
                        label="Screen"
                        testIdPrefix="prop-frame-screen"
                    />
                    <p className="text-[11px] text-muted-foreground/70">
                        Only visible when no screenshot is set on this frame.
                    </p>
                </div>
            )}
            {showStroke && !useReal && (
                <>
            <NumberField
                label="Stroke width"
                value={strokeWidth}
                min={0}
                onCommit={(v) =>
                    onUpdate((draft) => {
                        if (draft.type !== 'frame') return;
                        const existing = draft.style?.stroke;
                        if (v <= 0) {
                            draft.style = { ...draft.style, stroke: undefined };
                        } else {
                            draft.style = {
                                ...draft.style,
                                stroke: {
                                    ...existing,
                                    color: existing?.color ?? '#0F172A',
                                    width: v,
                                },
                            };
                        }
                    })
                }
                testId="prop-frame-stroke-width"
            />
            {showStrokeColor && (
                <FrameStrokePaint layer={layer} onUpdate={onUpdate} />
            )}
            {showStrokeColor && (
                <div className="space-y-1.5">
                    <Label>Stroke position</Label>
                    <Segmented
                        value={layer.style?.stroke?.position ?? 'center'}
                        options={[
                            { value: 'center', label: 'Center' },
                            { value: 'inside', label: 'Inside' },
                            { value: 'outside', label: 'Outside' },
                        ]}
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'frame') return;
                                const existing = draft.style?.stroke;
                                if (!existing) return;
                                draft.style = {
                                    ...draft.style,
                                    stroke: {
                                        ...existing,
                                        position: v === 'center' ? undefined : v,
                                    },
                                };
                            })
                        }
                        testIdPrefix="prop-frame-stroke-position"
                    />
                </div>
            )}
            {showStrokeColor && (
                <div className="space-y-1.5">
                    <Label>Stroke style</Label>
                    <Segmented<StrokeDash>
                        value={layer.style?.stroke?.dash ?? 'solid'}
                        options={[
                            { value: 'solid', label: 'Solid' },
                            { value: 'dashed', label: 'Dashed' },
                            { value: 'dotted', label: 'Dotted' },
                        ]}
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'frame') return;
                                const existing = draft.style?.stroke;
                                const base = existing ?? {
                                    color: '#0F172A',
                                    width: 1,
                                };
                                draft.style = {
                                    ...draft.style,
                                    stroke: {
                                        ...base,
                                        dash: v === 'solid' ? undefined : v,
                                    },
                                };
                            })
                        }
                        testIdPrefix="prop-frame-stroke-dash"
                    />
                </div>
            )}
            {/* Frame dash gap — mirror of the shape control. Default 2; lower
                tightens, higher spreads. See shape comment for the rationale
                behind the Dash-offset → Dash-gap rename. */}
            {layer.style?.stroke?.dash && layer.style.stroke.dash !== 'solid' && (
                <SliderField
                    label="Dash gap"
                    value={layer.style.stroke.dashGap ?? 2}
                    min={0.5}
                    max={8}
                    step={0.25}
                    suffix="×"
                    onChange={(v) =>
                        onUpdate((draft) => {
                            if (draft.type !== 'frame') return;
                            const existing = draft.style?.stroke;
                            if (!existing) return;
                            draft.style = {
                                ...draft.style,
                                stroke: {
                                    ...existing,
                                    dashGap: Math.abs(v - 2) < 0.01 ? undefined : v,
                                },
                            };
                        })
                    }
                    testId="prop-frame-stroke-dash-gap"
                />
            )}
            {showStrokeColor && (
                <div className="space-y-1.5">
                    <Label>Line join</Label>
                    <Segmented<LineJoin>
                        value={layer.style?.stroke?.lineJoin ?? 'miter'}
                        options={[
                            { value: 'miter', label: 'Miter' },
                            { value: 'round', label: 'Round' },
                            { value: 'bevel', label: 'Bevel' },
                        ]}
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'frame') return;
                                const existing = draft.style?.stroke ?? {
                                    color: '#0F172A',
                                    width: 1,
                                };
                                draft.style = {
                                    ...draft.style,
                                    stroke: {
                                        ...existing,
                                        lineJoin: v === 'miter' ? undefined : v,
                                    },
                                };
                            })
                        }
                        testIdPrefix="prop-frame-line-join"
                    />
                </div>
            )}
                </>
            )}
            {showFrameScreen && projectId != null && (
                <FrameScreenControls
                    layer={layer}
                    projectId={projectId}
                />
            )}
            {showFrameTransform && (
                <FrameTransformControls
                    layer={layer}
                    onUpdate={onUpdate}
                    useReal={useReal}
                />
            )}
            {showShadow && (
                <ShadowControls
                    layer={layer}
                    onUpdate={onUpdate}
                    testIdPrefix="prop-frame-shadow"
                />
            )}
        </Section>
    );
}

function FrameStrokePaint({ layer, onUpdate }: FrameProps) {
    const stroke: Color =
        strokeColorAsColor(layer.style?.stroke?.color) ??
        { type: 'solid', value: '#0F172A' };

    const setStroke = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            const existing = draft.style?.stroke;
            draft.style = {
                ...draft.style,
                stroke: {
                    ...existing,
                    color: next,
                    width: existing?.width ?? 1,
                },
            };
        });

    return (
        <StrokePaintPicker
            value={stroke}
            onChange={setStroke}
            defaultColor="#0F172A"
            label="Stroke color"
            testIdPrefix="prop-frame-stroke"
        />
    );
}

/**
 * Skew/tilt/flip on the frame as a whole — bezel + bound screen render together
 * so these transforms apply uniformly. Crop and image-adjust filters live on
 * `frame.screen`, not here, since they only make sense on the inner raster.
 */
function FrameTransformControls({
    layer,
    onUpdate,
    useReal,
}: {
    layer: Extract<Layer, { type: 'frame' }>;
    onUpdate: LayerPropsProps['onUpdate'];
    useReal: boolean;
}) {
    // Real frames render via photoreal PNGs whose perspective math caps out at
    // ~15° before edge artifacts appear; beyond that the renderer falls back
    // to procedural bezel. Tighten the slider range so the cap is visible at
    // the control instead of as a silent downgrade.
    const tiltMax = useReal ? 15 : 45;
    const flipX = layer.flipX === true;
    const flipY = layer.flipY === true;
    const skewX = layer.skewX ?? 0;
    const skewY = layer.skewY ?? 0;
    const tiltY = layer.tiltY ?? 0;
    const tiltX = layer.tiltX ?? 0;
    const hasTransform =
        Math.abs(skewX) > 0.01 ||
        Math.abs(skewY) > 0.01 ||
        Math.abs(tiltY) > 0.01 ||
        Math.abs(tiltX) > 0.01 ||
        flipX ||
        flipY;

    const toggleFlip = (axis: 'flipX' | 'flipY') =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            if (draft[axis]) delete draft[axis];
            else draft[axis] = true;
        });
    const setSkew = (axis: 'skewX' | 'skewY', v: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            if (Math.abs(v) < 0.01) delete draft[axis];
            else draft[axis] = v;
        });
    const setTilt = (axis: 'tiltX' | 'tiltY', v: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            if (Math.abs(v) < 0.01) delete draft[axis];
            else draft[axis] = v;
        });
    const reset = () =>
        onUpdate((draft) => {
            if (draft.type !== 'frame') return;
            delete draft.flipX;
            delete draft.flipY;
            delete draft.skewX;
            delete draft.skewY;
            delete draft.tiltX;
            delete draft.tiltY;
        });

    return (
        <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
            <div className="flex items-center justify-between">
                <Label>Frame transform</Label>
                <button
                    type="button"
                    onClick={reset}
                    disabled={!hasTransform}
                    data-testid="prop-frame-transform-reset"
                    className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                >
                    Reset
                </button>
            </div>
            <div className="flex items-center justify-between">
                <span className="text-xs text-muted-foreground">Flip H</span>
                <SwitchToggle
                    checked={flipX}
                    onChange={() => toggleFlip('flipX')}
                    testId="prop-frame-flip-x"
                />
            </div>
            <div className="flex items-center justify-between">
                <span className="text-xs text-muted-foreground">Flip V</span>
                <SwitchToggle
                    checked={flipY}
                    onChange={() => toggleFlip('flipY')}
                    testId="prop-frame-flip-y"
                />
            </div>
            <SliderField
                label="Skew X"
                value={Math.round(skewX)}
                min={-45}
                max={45}
                suffix="°"
                onChange={(v) => setSkew('skewX', v)}
                testId="prop-frame-skew-x"
            />
            <SliderField
                label="Skew Y"
                value={Math.round(skewY)}
                min={-45}
                max={45}
                suffix="°"
                onChange={(v) => setSkew('skewY', v)}
                testId="prop-frame-skew-y"
            />
            <SliderField
                label="3D Tilt Y"
                value={Math.round(tiltY)}
                min={-tiltMax}
                max={tiltMax}
                suffix="°"
                onChange={(v) => setTilt('tiltY', v)}
                testId="prop-frame-tilt-y"
            />
            <SliderField
                label="3D Tilt X"
                value={Math.round(tiltX)}
                min={-tiltMax}
                max={tiltMax}
                suffix="°"
                onChange={(v) => setTilt('tiltX', v)}
                testId="prop-frame-tilt-x"
            />
            {useReal && (
                <Tooltip
                    side="top"
                    multiline
                    label="Real frames support gentle tilt only (±15°). For dramatic angles, switch off Real frame to use the procedural bezel."
                >
                    <p
                        data-testid="prop-frame-tilt-cap-hint"
                        className="cursor-help text-[11px] text-muted-foreground"
                    >
                        Real frames cap tilt at ±15°.
                    </p>
                </Tooltip>
            )}
        </div>
    );
}

/**
 * "Master" pill matching the locale/factor badge styling — clarifies that
 * the top "Frame screen" upload is the default that the per-locale list
 * below overrides. Hover surfaces a longer tooltip; the label below the
 * section header carries the same idea in prose.
 */
/**
 * Hint line surfacing the target pixel dimensions for the frame's bound
 * screenshot, taken from the device preset's `export_w/h` (the device's
 * native screen rect). When the uploaded image's natural size doesn't
 * match the target, switches to a destructive amber line showing both the
 * recommended and the actual dims so the user can re-crop or re-export
 * before shipping. Falls back silently when the preset isn't loaded yet.
 */
function RecommendedScreenshotDims({
    preset,
    actualWidth,
    actualHeight,
    mismatch,
    testId,
}: {
    preset?: DevicePresetDto;
    actualWidth?: number;
    actualHeight?: number;
    mismatch?: boolean;
    testId: string;
}) {
    if (!preset) return null;
    const target = `${preset.export_w.toLocaleString()} × ${preset.export_h.toLocaleString()} px`;
    if (mismatch && actualWidth && actualHeight) {
        const actual = `${actualWidth.toLocaleString()} × ${actualHeight.toLocaleString()} px`;
        return (
            <p
                className="text-[10px] font-medium text-warning"
                data-testid={testId}
                title={`Uploaded screenshot doesn't match ${preset.label}'s native screen size. The renderer scales to fit, but you'll lose pixel sharpness vs uploading at the target dims.`}
            >
                Uploaded {actual} · expected{' '}
                <span className="font-mono">{target}</span> ({preset.label})
            </p>
        );
    }
    return (
        <p
            className="text-[10px] text-muted-foreground/80"
            data-testid={testId}
        >
            Recommended:{' '}
            <span className="font-mono text-foreground/80">{target}</span> ({preset.label})
        </p>
    );
}

function FrameScreenMasterBadge() {
    return (
        <Tooltip
            label="Default screenshot. Per-locale uploads below override it for translated languages."
            side="bottom"
            multiline
        >
            <span
                data-testid="prop-frame-screen-master-pill"
                className="rounded-sm bg-accent/10 px-1 text-[9px] font-medium uppercase tracking-wider text-accent"
            >
                Default
            </span>
        </Tooltip>
    );
}

/**
 * Bound app-screen image on a frame layer (`FrameLayer.screen`). When unset,
 * shows an upload control. When set, shows the source path + replace/clear
 * actions plus fit/pan/zoom/crop/filter controls that mirror the standalone
 * image layer's UI but write through `updateFrameScreen` instead of
 * `updateLayer`.
 */
function FrameScreenControls({
    layer,
    projectId,
}: {
    layer: Extract<Layer, { type: 'frame' }>;
    projectId: number;
}) {
    const setFrameScreen = useEditor((s) => s.setFrameScreen);
    const updateFrameScreen = useEditor((s) => s.updateFrameScreen);
    const clearFrameScreen = useEditor((s) => s.clearFrameScreen);
    const setFrameScreenLocalePath = useEditor((s) => s.setFrameScreenLocalePath);
    const storeActiveLocale = useEditor((s) => s.activeLocale);
    const storeDefaultLocale = useEditor((s) => s.defaultLocale);
    /**
     * The "Frame screen" upload writes to `screen.storagePath` — the default-
     * locale source the per-locale overrides fall back to. Editing it from a
     * non-default-locale context is almost always wrong: the user's intent
     * off-default is to override for THIS locale, not to retroactively change
     * every locale's fallback. We surface it only on default-locale context;
     * off-default, the per-locale list below lets the user upload to the
     * current locale's specific row.
     */
    const onMasterContext = storeActiveLocale === storeDefaultLocale;

    const inputRef = useRef<HTMLInputElement | null>(null);
    const localeInputRef = useRef<HTMLInputElement | null>(null);
    const [uploading, setUploading] = useState(false);
    /** Locale whose row triggered the latest non-default upload. */
    const [pendingLocale, setPendingLocale] = useState<string | null>(null);
    const [localeUploading, setLocaleUploading] = useState<string | null>(null);
    const toast = useToast();
    // Hooks must run on every render — keep them above the !screen early
    // return below. useImage tolerates an undefined URL.
    const devicePresets = useEditor((s) => s.devicePresets);
    const { image: screenImage } = useImage(mediaUrl(layer.screen?.storagePath));
    // Recommended screenshot dimensions = the device preset's `export_w/h`
    // (the native screen pixel rect inside the bezel). Surfaced near the
    // upload/replace controls so the user picks a screenshot that fills the
    // device cleanly. Also drives a "dims mismatch" warning when the
    // uploaded screenshot's natural size differs from the target.
    const devicePreset = devicePresets.find((p) => p.id === layer.devicePresetId);
    const expectedW = devicePreset?.export_w ?? 0;
    const expectedH = devicePreset?.export_h ?? 0;
    const screenshotMismatch =
        devicePreset != null &&
        screenImage != null &&
        screenImage.naturalWidth > 0 &&
        screenImage.naturalHeight > 0 &&
        (screenImage.naturalWidth !== expectedW ||
            screenImage.naturalHeight !== expectedH);

    const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        e.target.value = '';
        if (!file) return;
        setUploading(true);
        const toastId = toast.show({
            kind: 'loading',
            message: `Uploading ${file.name}`,
            progress: 0,
        });
        try {
            const media = await uploadMedia(projectId, file, {
                onProgress: (fraction) => toast.update(toastId, { progress: fraction }),
            });
            // First upload (no existing screen): create fresh.
            // Replace (screen already exists): only swap storagePath +
            // localePaths default; preserve user adjustments (offsetX/Y,
            // zoom, crop, adjust, statusBarOverlay, fit). Without this
            // guard, every "Replace screenshot…" click reset the user's
            // pan / zoom / color filters they'd painstakingly dialed in.
            if (layer.screen) {
                updateFrameScreen(layer.id, (draft) => {
                    draft.storagePath = media.storagePath;
                });
            } else {
                setFrameScreen(
                    layer.id,
                    createFrameScreen({
                        storagePath: media.storagePath,
                        width: media.width,
                        height: media.height,
                        fileName: file.name,
                    }),
                );
            }
            toast.dismiss(toastId);
            toast.success(`Uploaded ${file.name}`);
        } catch (err) {
            toast.dismiss(toastId);
            toast.error(err instanceof Error ? err.message : 'Upload failed');
        } finally {
            setUploading(false);
        }
    };

    const onPickLocaleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        const locale = pendingLocale;
        e.target.value = '';
        setPendingLocale(null);
        if (!file || !locale) return;
        setLocaleUploading(locale);
        const toastId = toast.show({
            kind: 'loading',
            message: `Uploading ${file.name} (${locale})`,
            progress: 0,
        });
        try {
            const media = await uploadMedia(projectId, file, {
                onProgress: (fraction) => toast.update(toastId, { progress: fraction }),
            });
            await setFrameScreenLocalePath(layer.id, locale, media.storagePath);
            toast.dismiss(toastId);
            toast.success(`Uploaded ${file.name}`);
        } catch (err) {
            toast.dismiss(toastId);
            toast.error(err instanceof Error ? err.message : 'Upload failed');
        } finally {
            setLocaleUploading(null);
        }
    };

    const startLocaleUpload = (locale: string) => {
        setPendingLocale(locale);
        localeInputRef.current?.click();
    };

    const clearLocalePath = async (locale: string) => {
        setLocaleUploading(locale);
        try {
            await setFrameScreenLocalePath(layer.id, locale, null);
        } catch (err) {
            toast.error(err instanceof Error ? err.message : 'Clear failed');
        } finally {
            setLocaleUploading(null);
        }
    };

    const screen = layer.screen;

    if (!screen) {
        // Empty state — first screenshot upload must land on the default
        // locale (`screen.storagePath` is the fallback every per-locale
        // override resolves through). Off-default-locale we hide the
        // primary upload so a "first upload" can't accidentally become a
        // locale-only override of a screenshot that doesn't exist yet.
        if (!onMasterContext) {
            return (
                <div
                    className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-2.5 text-[11px] text-muted-foreground"
                    data-testid="prop-frame-screen-needs-master"
                >
                    Add the first screenshot on{' '}
                    <span className="font-medium text-foreground">
                        {storeDefaultLocale}
                    </span>{' '}
                    (default locale). Per-locale overrides appear here once
                    a default screenshot is set.
                </div>
            );
        }
        return (
            <div className="space-y-1.5">
                <div className="flex items-baseline gap-2">
                    <Label>Upload Screenshot</Label>
                    <FrameScreenMasterBadge />
                </div>
                <input
                    ref={inputRef}
                    type="file"
                    accept="image/*"
                    className="hidden"
                    onChange={onPickFile}
                    data-testid="prop-frame-screen-upload-input"
                />
                <button
                    type="button"
                    onClick={() => inputRef.current?.click()}
                    disabled={uploading}
                    data-testid="prop-frame-screen-upload"
                    className="flex h-20 w-full flex-col items-center justify-center gap-1.5 rounded-md border border-dashed border-border bg-muted/20 text-[11px] font-medium text-muted-foreground transition-colors hover:border-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-60"
                >
                    <Upload className="h-4 w-4" strokeWidth={1.5} />
                    <span>{uploading ? 'Uploading…' : 'Add screenshot'}</span>
                </button>
                <RecommendedScreenshotDims
                    preset={devicePreset}
                    testId="prop-frame-screen-recommended-dims"
                />
            </div>
        );
    }

    const fit = screen.fit ?? 'cover';
    const canPanZoom = fit === 'cover' || fit === 'contain';
    const zoom = screen.zoom ?? 1;
    const offsetX = screen.offsetX ?? 0;
    const offsetY = screen.offsetY ?? 0;

    // Anchor presets (3x3 grid) — compute the offsetX/Y values that align
    // an image edge with the inner-screen rect's edge for the active fit.
    // Math derives from `fitBoxDims` in LayerNode: y = (innerH - actualH)/2
    // + offsetY * innerH; solving for y=0 (top) or y=innerH-actualH (bottom)
    // gives `±overflow / (2 * innerH)` where overflow = actualH - innerH.
    // Using aspect ratios so we don't need outer pixel rect; bezel inset
    // shifts the inner aspect very slightly which the slider can fine-tune.
    const preset = devicePresets.find((p) => p.id === layer.devicePresetId);
    const innerAR =
        preset && preset.export_w > 0 ? preset.export_h / preset.export_w : 0;
    const imgAR =
        screenImage && screenImage.naturalWidth > 0
            ? screenImage.naturalHeight / screenImage.naturalWidth
            : 0;
    const overflowOffsetY =
        innerAR > 0 && imgAR > 0 && imgAR > innerAR
            ? (imgAR / innerAR - 1) / 2
            : 0;
    const overflowOffsetX =
        innerAR > 0 && imgAR > 0 && imgAR < innerAR
            ? (innerAR / imgAR - 1) / 2
            : 0;
    const xActive = overflowOffsetX > 0.01;
    const yActive = overflowOffsetY > 0.01;
    const setAnchor = (vx: -1 | 0 | 1, vy: -1 | 0 | 1) =>
        updateFrameScreen(layer.id, (draft) => {
            const ox = xActive ? vx * overflowOffsetX : 0;
            const oy = yActive ? vy * overflowOffsetY : 0;
            if (Math.abs(ox) < 0.001) delete draft.offsetX;
            else draft.offsetX = ox;
            if (Math.abs(oy) < 0.001) delete draft.offsetY;
            else draft.offsetY = oy;
        });
    const anchorActiveTol = 0.01;
    // Only the center of an axis is canonical when that axis has no overflow —
    // otherwise multiple cells map to the same physical position and would all
    // light up as active.
    const anchorActive = (vx: -1 | 0 | 1, vy: -1 | 0 | 1) => {
        if (!xActive && vx !== 0) return false;
        if (!yActive && vy !== 0) return false;
        const targetX = xActive ? vx * overflowOffsetX : 0;
        const targetY = yActive ? vy * overflowOffsetY : 0;
        return (
            Math.abs(offsetX - targetX) < anchorActiveTol &&
            Math.abs(offsetY - targetY) < anchorActiveTol
        );
    };
    const anchorEnabled = (vx: -1 | 0 | 1, vy: -1 | 0 | 1) =>
        (xActive || vx === 0) && (yActive || vy === 0);

    const crop: ImageCrop = screen.crop ?? {};
    const cropTop = crop.top ?? 0;
    const cropRight = crop.right ?? 0;
    const cropBottom = crop.bottom ?? 0;
    const cropLeft = crop.left ?? 0;
    const hasCrop = cropTop > 0 || cropRight > 0 || cropBottom > 0 || cropLeft > 0;

    const adjust: ImageAdjust = screen.adjust ?? {};
    const tintColor = adjust.tint?.color ?? '#0052FF';
    const tintAmount = adjust.tint?.amount ?? 0;
    const hasAdjust =
        (adjust.brightness ?? 0) !== 0 ||
        (adjust.contrast ?? 0) !== 0 ||
        (adjust.saturation ?? 0) !== 0 ||
        (adjust.hue ?? 0) !== 0 ||
        (adjust.blur ?? 0) > 0 ||
        adjust.grayscale === true ||
        adjust.invert === true ||
        tintAmount > 0;

    const setFit = (v: 'cover' | 'contain' | 'fill') =>
        updateFrameScreen(layer.id, (draft) => {
            draft.fit = v;
        });
    const setZoom = (v: number) =>
        updateFrameScreen(layer.id, (draft) => {
            if (Math.abs(v - 1) < 0.001) delete draft.zoom;
            else draft.zoom = v;
        });
    const setOffset = (axis: 'offsetX' | 'offsetY', v: number) =>
        updateFrameScreen(layer.id, (draft) => {
            if (Math.abs(v) < 0.001) delete draft[axis];
            else draft[axis] = v;
        });
    const setCrop = (side: keyof ImageCrop, v: number) =>
        updateFrameScreen(layer.id, (draft) => {
            const next: ImageCrop = { ...(draft.crop ?? {}) };
            if (v <= 0) delete next[side];
            else next[side] = v;
            if (Object.keys(next).length === 0) delete draft.crop;
            else draft.crop = next;
        });
    const resetCrop = () =>
        updateFrameScreen(layer.id, (draft) => {
            delete draft.crop;
        });
    const setAdjust = (patch: Partial<ImageAdjust>) =>
        updateFrameScreen(layer.id, (draft) => {
            const next: ImageAdjust = { ...(draft.adjust ?? {}), ...patch };
            (Object.keys(next) as Array<keyof ImageAdjust>).forEach((k) => {
                const v = next[k];
                if (v == null) delete next[k];
                else if (typeof v === 'number' && v === 0) delete next[k];
                else if (typeof v === 'boolean' && v === false) delete next[k];
                else if (k === 'tint' && (v as { amount: number }).amount <= 0)
                    delete next[k];
            });
            if (Object.keys(next).length === 0) delete draft.adjust;
            else draft.adjust = next;
        });
    const resetAdjust = () =>
        updateFrameScreen(layer.id, (draft) => {
            delete draft.adjust;
        });

    return (
        <div className="space-y-3">
            {onMasterContext && (
                <div className="space-y-1.5">
                    <div className="flex items-center justify-between gap-2">
                        <div className="flex items-baseline gap-2">
                            <Label>Upload Screenshot</Label>
                            <FrameScreenMasterBadge />
                        </div>
                        <button
                            type="button"
                            onClick={() => clearFrameScreen(layer.id)}
                            data-testid="prop-frame-screen-clear"
                            className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive"
                        >
                            Clear
                        </button>
                    </div>
                    <input
                        ref={inputRef}
                        type="file"
                        accept="image/*"
                        className="hidden"
                        onChange={onPickFile}
                        data-testid="prop-frame-screen-replace-input"
                    />
                    <button
                        type="button"
                        onClick={() => inputRef.current?.click()}
                        disabled={uploading}
                        data-testid="prop-frame-screen-replace"
                        className="group relative flex h-20 w-full items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30 transition-colors hover:border-accent disabled:pointer-events-none disabled:opacity-60"
                    >
                        <img
                            src={mediaUrl(screen.storagePath)}
                            alt=""
                            className="h-full w-full object-contain"
                            data-testid="prop-frame-screen-source"
                        />
                        <span className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 text-[11px] font-medium text-white opacity-0 transition-opacity group-hover:opacity-100">
                            <ImageIcon className="h-3.5 w-3.5" strokeWidth={2} />
                            {uploading ? 'Uploading…' : 'Replace'}
                        </span>
                    </button>
                    <RecommendedScreenshotDims
                        preset={devicePreset}
                        actualWidth={
                            screenImage?.naturalWidth && screenImage.naturalWidth > 0
                                ? screenImage.naturalWidth
                                : undefined
                        }
                        actualHeight={
                            screenImage?.naturalHeight && screenImage.naturalHeight > 0
                                ? screenImage.naturalHeight
                                : undefined
                        }
                        mismatch={screenshotMismatch}
                        testId="prop-frame-screen-recommended-dims"
                    />
                </div>
            )}
            <FrameScreenLocaleList
                layer={layer}
                localeInputRef={localeInputRef}
                onPickLocaleFile={onPickLocaleFile}
                onLocaleUpload={startLocaleUpload}
                onLocaleClear={clearLocalePath}
                localeUploading={localeUploading}
            />
            <StatusBarOverlayControls
                layer={layer}
                screenImage={screenImage}
                onUpdate={updateFrameScreen}
            />
            <div className="space-y-1.5" data-testid="prop-frame-screen-fit-row">
                <Label>Fit</Label>
                <Segmented
                    value={fit}
                    options={[
                        { value: 'cover', label: 'Cover' },
                        { value: 'contain', label: 'Contain' },
                        { value: 'fill', label: 'Fill' },
                    ]}
                    onChange={(v) => setFit(v as 'cover' | 'contain' | 'fill')}
                    testIdPrefix="prop-frame-screen-fit"
                />
            </div>
            {canPanZoom && (
                <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                    <Label>Pan &amp; zoom</Label>
                    {(xActive || yActive) && (
                        <div className="space-y-1.5">
                            <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80">
                                Anchor
                            </span>
                            <div
                                className="grid grid-cols-3 gap-1"
                                data-testid="prop-frame-screen-anchor"
                            >
                                {(
                                    [
                                        [1, 1, 'top-left', 'Top-left'],
                                        [0, 1, 'top', 'Top'],
                                        [-1, 1, 'top-right', 'Top-right'],
                                        [1, 0, 'middle-left', 'Middle-left'],
                                        [0, 0, 'center', 'Center'],
                                        [-1, 0, 'middle-right', 'Middle-right'],
                                        [1, -1, 'bottom-left', 'Bottom-left'],
                                        [0, -1, 'bottom', 'Bottom'],
                                        [-1, -1, 'bottom-right', 'Bottom-right'],
                                    ] as Array<[-1 | 0 | 1, -1 | 0 | 1, string, string]>
                                ).map(([vx, vy, key, label]) => {
                                    const isActive = anchorActive(vx, vy);
                                    const isEnabled = anchorEnabled(vx, vy);
                                    return (
                                        <button
                                            key={key}
                                            type="button"
                                            onClick={() => setAnchor(vx, vy)}
                                            disabled={!isEnabled}
                                            data-testid={`prop-frame-screen-anchor-${key}`}
                                            data-active={isActive ? 'true' : 'false'}
                                            aria-pressed={isActive}
                                            title={isEnabled ? label : `${label} (no overflow on this axis)`}
                                            className={cn(
                                                'flex h-6 items-center justify-center rounded-md border text-[10px] font-medium transition-colors',
                                                isActive
                                                    ? 'border-accent bg-accent/10 text-accent'
                                                    : 'border-border bg-card text-muted-foreground hover:text-foreground',
                                                !isEnabled && 'pointer-events-none opacity-30',
                                            )}
                                        >
                                            <span
                                                className={cn(
                                                    'block h-1.5 w-1.5 rounded-full',
                                                    isActive ? 'bg-accent' : 'bg-muted-foreground/50',
                                                )}
                                            />
                                        </button>
                                    );
                                })}
                            </div>
                        </div>
                    )}
                    <SliderField
                        label="Zoom"
                        value={Math.round(zoom * 100)}
                        min={50}
                        max={300}
                        suffix="%"
                        onChange={(v) => setZoom(v / 100)}
                        testId="prop-frame-screen-zoom"
                    />
                    <SliderField
                        label="Offset X"
                        value={Math.round(offsetX * 100)}
                        min={-100}
                        max={100}
                        suffix="%"
                        onChange={(v) => setOffset('offsetX', v / 100)}
                        testId="prop-frame-screen-offset-x"
                    />
                    <SliderField
                        label="Offset Y"
                        value={Math.round(offsetY * 100)}
                        min={-100}
                        max={100}
                        suffix="%"
                        onChange={(v) => setOffset('offsetY', v / 100)}
                        testId="prop-frame-screen-offset-y"
                    />
                </div>
            )}
            <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                <div className="flex items-center justify-between">
                    <Label>Crop</Label>
                    <button
                        type="button"
                        onClick={resetCrop}
                        disabled={!hasCrop}
                        data-testid="prop-frame-screen-crop-reset"
                        className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                    >
                        Reset
                    </button>
                </div>
                <SliderField
                    label="Top"
                    value={Math.round(cropTop * 100)}
                    min={0}
                    max={99}
                    suffix="%"
                    onChange={(v) => setCrop('top', v / 100)}
                    testId="prop-frame-screen-crop-top"
                />
                <SliderField
                    label="Right"
                    value={Math.round(cropRight * 100)}
                    min={0}
                    max={99}
                    suffix="%"
                    onChange={(v) => setCrop('right', v / 100)}
                    testId="prop-frame-screen-crop-right"
                />
                <SliderField
                    label="Bottom"
                    value={Math.round(cropBottom * 100)}
                    min={0}
                    max={99}
                    suffix="%"
                    onChange={(v) => setCrop('bottom', v / 100)}
                    testId="prop-frame-screen-crop-bottom"
                />
                <SliderField
                    label="Left"
                    value={Math.round(cropLeft * 100)}
                    min={0}
                    max={99}
                    suffix="%"
                    onChange={(v) => setCrop('left', v / 100)}
                    testId="prop-frame-screen-crop-left"
                />
            </div>
            <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                <div className="flex items-center justify-between">
                    <Label>Adjust</Label>
                    <button
                        type="button"
                        onClick={resetAdjust}
                        disabled={!hasAdjust}
                        data-testid="prop-frame-screen-adjust-reset"
                        className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                    >
                        Reset
                    </button>
                </div>
                <SliderField
                    label="Brightness"
                    value={Math.round((adjust.brightness ?? 0) * 100)}
                    min={-100}
                    max={100}
                    suffix="%"
                    onChange={(v) => setAdjust({ brightness: v / 100 })}
                    testId="prop-frame-screen-adjust-brightness"
                />
                <SliderField
                    label="Contrast"
                    value={adjust.contrast ?? 0}
                    min={-100}
                    max={100}
                    onChange={(v) => setAdjust({ contrast: v })}
                    testId="prop-frame-screen-adjust-contrast"
                />
                <SliderField
                    label="Saturation"
                    value={Math.round((adjust.saturation ?? 0) * 100)}
                    min={-100}
                    max={100}
                    suffix="%"
                    onChange={(v) => setAdjust({ saturation: v / 100 })}
                    testId="prop-frame-screen-adjust-saturation"
                />
                <SliderField
                    label="Hue"
                    value={Math.round(adjust.hue ?? 0)}
                    min={-180}
                    max={180}
                    suffix="°"
                    onChange={(v) => setAdjust({ hue: v })}
                    testId="prop-frame-screen-adjust-hue"
                />
                <SliderField
                    label="Blur"
                    value={Math.round(adjust.blur ?? 0)}
                    min={0}
                    max={40}
                    suffix="px"
                    onChange={(v) => setAdjust({ blur: v })}
                    testId="prop-frame-screen-adjust-blur"
                />
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Grayscale</span>
                    <SwitchToggle
                        checked={adjust.grayscale === true}
                        onChange={() =>
                            setAdjust({ grayscale: !(adjust.grayscale === true) })
                        }
                        testId="prop-frame-screen-adjust-grayscale"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Invert</span>
                    <SwitchToggle
                        checked={adjust.invert === true}
                        onChange={() =>
                            setAdjust({ invert: !(adjust.invert === true) })
                        }
                        testId="prop-frame-screen-adjust-invert"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Tint color</span>
                    <input
                        type="color"
                        value={tintColor}
                        onChange={(e) =>
                            setAdjust({
                                tint: {
                                    color: e.target.value,
                                    amount: tintAmount > 0 ? tintAmount : 0.3,
                                },
                            })
                        }
                        data-testid="prop-frame-screen-adjust-tint-color"
                        className="h-7 w-10 cursor-pointer rounded-md border border-border bg-background p-0"
                    />
                </div>
                <SliderField
                    label="Tint amount"
                    value={Math.round(tintAmount * 100)}
                    min={0}
                    max={100}
                    suffix="%"
                    onChange={(v) =>
                        setAdjust({ tint: { color: tintColor, amount: v / 100 } })
                    }
                    testId="prop-frame-screen-adjust-tint-amount"
                />
            </div>
        </div>
    );
}

/**
 * Hide-status-bar overlay controls for the bound screenshot. Toggle paints a
 * solid-color rect over the top of the screen cutout — useful when the
 * uploaded screenshot was captured with the dev clock/battery showing instead
 * of Apple's 9:41 reference state. Color auto-detects from the screenshot's
 * top row when the user first enables; user can override via the color
 * picker and re-detect from the screenshot at any point.
 */
function StatusBarOverlayControls({
    layer,
    screenImage,
    onUpdate,
}: {
    layer: Extract<Layer, { type: 'frame' }>;
    screenImage: HTMLImageElement | undefined;
    onUpdate: (
        layerId: string,
        recipe: (draft: FrameScreen) => void,
    ) => void;
}) {
    const overlay = layer.screen?.statusBarOverlay;
    const enabled = overlay?.enabled === true;
    const color = overlay?.color ?? '#000000';
    const heightPct = overlay?.heightPct ?? 6;

    const setEnabled = (next: boolean) =>
        onUpdate(layer.id, (draft) => {
            if (!next) {
                // Preserve color + heightPct so re-enabling restores prior knob
                // values. Just flip the boolean.
                draft.statusBarOverlay = {
                    ...(draft.statusBarOverlay ?? {}),
                    enabled: false,
                };
                return;
            }
            // Enabling fresh — seed color via auto-detect when possible.
            const detected = screenImage
                ? detectStatusBarColor(screenImage)
                : null;
            draft.statusBarOverlay = {
                enabled: true,
                color: draft.statusBarOverlay?.color ?? detected ?? '#000000',
                heightPct: draft.statusBarOverlay?.heightPct ?? 6,
            };
        });

    const setColor = (hex: string) =>
        onUpdate(layer.id, (draft) => {
            draft.statusBarOverlay = {
                ...(draft.statusBarOverlay ?? { enabled: true, heightPct: 6 }),
                color: hex,
            };
        });

    const setHeight = (pct: number) =>
        onUpdate(layer.id, (draft) => {
            draft.statusBarOverlay = {
                ...(draft.statusBarOverlay ?? { enabled: true, color: '#000000' }),
                heightPct: Math.max(0, Math.min(30, pct)),
            };
        });

    const redetect = () => {
        if (!screenImage) return;
        const next = detectStatusBarColor(screenImage);
        if (next) setColor(next);
    };

    return (
        <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
            <div className="flex items-center justify-between">
                <Label>Hide status bar</Label>
                <SwitchToggle
                    checked={enabled}
                    onChange={() => setEnabled(!enabled)}
                    testId="prop-frame-screen-status-bar-toggle"
                />
            </div>
            {enabled && (
                <>
                    <div className="flex items-center justify-between">
                        <span className="text-xs text-muted-foreground">Color</span>
                        <div className="flex items-center gap-1.5">
                            <input
                                type="color"
                                value={color}
                                onChange={(e) => setColor(e.target.value)}
                                data-testid="prop-frame-screen-status-bar-color"
                                className="h-7 w-10 cursor-pointer rounded-md border border-border bg-background p-0"
                            />
                            <button
                                type="button"
                                onClick={redetect}
                                disabled={!screenImage}
                                title="Re-detect from screenshot"
                                aria-label="Re-detect color from screenshot"
                                data-testid="prop-frame-screen-status-bar-redetect"
                                className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                            >
                                <RefreshCw className="h-3.5 w-3.5" strokeWidth={2} />
                            </button>
                        </div>
                    </div>
                    <SliderField
                        label="Height"
                        value={heightPct}
                        min={1}
                        max={20}
                        step={0.5}
                        suffix="%"
                        onChange={setHeight}
                        testId="prop-frame-screen-status-bar-height"
                    />
                </>
            )}
        </div>
    );
}

/**
 * Per-locale screenshot list for a frame layer. Default-locale row reads
 * `screen.storagePath`; non-default rows read `screen.localePaths[locale]`.
 * Resolver swaps `storagePath` to the matching locale's path at render time
 * (Arabic-tagged screenshot wins when user is on Arabic).
 *
 * Auto-hides for single-locale projects.
 */
function FrameScreenLocaleList({
    layer,
    localeInputRef,
    onPickLocaleFile,
    onLocaleUpload,
    onLocaleClear,
    localeUploading,
}: {
    layer: Extract<Layer, { type: 'frame' }>;
    localeInputRef: React.RefObject<HTMLInputElement | null>;
    onPickLocaleFile: (e: React.ChangeEvent<HTMLInputElement>) => void;
    onLocaleUpload: (locale: string) => void;
    onLocaleClear: (locale: string) => Promise<void>;
    localeUploading: string | null;
}) {
    const localePaths = layer.screen?.localePaths ?? {};
    const activeLocale = useEditor((s) => s.activeLocale);
    const activeLocales = useEditor((s) => s.activeLocales);
    const defaultLocale = useEditor((s) => s.defaultLocale);
    const allLocales = usePage<SharedProps>().props.locales ?? [];

    // Single-locale projects don't need a localized-screenshot pane —
    // hide the section entirely.
    if (activeLocales.length < 2) return null;

    const rowFor = (code: string): LocaleDto => {
        const row = allLocales.find((l) => l.code === code);
        return (
            row ?? {
                code,
                name: code.toUpperCase(),
                native_name: code.toUpperCase(),
                direction: 'ltr' as const,
                font_stack: '',
            }
        );
    };

    return (
        <div className="space-y-1.5" data-testid="prop-frame-screen-locale-list">
            <Label>Per-locale screenshots</Label>
            <p className="text-[10px] text-muted-foreground/80">
                Override the master screenshot for a translated locale (UI text differs by language). Locale beats per-factor on cross views.
            </p>
            <input
                ref={localeInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={onPickLocaleFile}
                data-testid="prop-frame-screen-locale-input"
            />
            <ul className="divide-y divide-border overflow-hidden rounded-md border border-border bg-background/40">
                {activeLocales.map((locale) => {
                    const isDefault = locale === defaultLocale;
                    const isActive = locale === activeLocale;
                    const path = isDefault ? '' : localePaths[locale] ?? '';
                    const isSet = !isDefault && path !== '';
                    const filename = isSet
                        ? path.split('/').pop() ?? path
                        : '';
                    const busy = localeUploading === locale;
                    const uploadLabel = busy
                        ? 'Uploading…'
                        : isSet
                            ? 'Replace'
                            : 'Upload';
                    return (
                        <li
                            key={locale}
                            data-testid={`prop-frame-screen-locale-row-${locale}`}
                            data-active={isActive ? 'true' : 'false'}
                            className={cn(
                                'flex items-center gap-2 px-2 py-1.5',
                                isActive && 'bg-accent/5',
                            )}
                        >
                            <span
                                className={cn(
                                    'flex h-4 w-4 shrink-0 items-center justify-center rounded-full',
                                    isSet || isDefault
                                        ? 'text-success'
                                        : 'text-muted-foreground/40',
                                )}
                                aria-hidden
                            >
                                {isSet || isDefault ? (
                                    <Check className="h-3.5 w-3.5" strokeWidth={2.5} />
                                ) : (
                                    <Circle className="h-2 w-2 fill-current" strokeWidth={0} />
                                )}
                            </span>
                            <div className="flex min-w-0 flex-1 flex-col">
                                {/* Row stays LTR — see comment in
                                  * Projects/Index.tsx LocaleAddGroup. */}
                                <span className="flex min-w-0 items-baseline gap-1 text-[11px] font-medium text-foreground">
                                    <span className="shrink-0">{rowFor(locale).native_name}</span>
                                    <span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">
                                        · {locale}
                                    </span>
                                    {isDefault && (
                                        <span className="shrink-0 text-muted-foreground">(default)</span>
                                    )}
                                </span>
                                <span className="truncate text-[10px] text-muted-foreground">
                                    {isDefault
                                        ? 'Falls through to the master screenshot.'
                                        : isSet
                                            ? filename
                                            : 'Inherits from master.'}
                                </span>
                            </div>
                            {!isDefault && (
                                <div className="flex shrink-0 items-center gap-1">
                                    <button
                                        type="button"
                                        onClick={() => onLocaleUpload(locale)}
                                        disabled={busy}
                                        data-testid={`prop-frame-screen-locale-upload-${locale}`}
                                        className="rounded-md border border-border bg-card px-2 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                                    >
                                        {uploadLabel}
                                    </button>
                                    {isSet && (
                                        <button
                                            type="button"
                                            onClick={() => void onLocaleClear(locale)}
                                            disabled={busy}
                                            data-testid={`prop-frame-screen-locale-clear-${locale}`}
                                            aria-label={`Clear ${locale} screenshot`}
                                            className="rounded-md border border-border bg-card p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                                        >
                                            <X className="h-3 w-3" strokeWidth={2} />
                                        </button>
                                    )}
                                </div>
                            )}
                        </li>
                    );
                })}
            </ul>
        </div>
    );
}

function PathControls({ layer, onUpdate }: ShapeProps) {
    const nodes = layer.nodes ?? [];
    const closed = Boolean(layer.closed);
    const canClose = nodes.length >= 3;

    const smoothAll = () =>
        onUpdate((draft) => {
            if (draft.type !== 'shape' || draft.shape !== 'path') return;
            const src = draft.nodes ?? [];
            if (src.length < 2) return;
            const cl = Boolean(draft.closed);
            draft.nodes = src.map((n, i) => {
                const prev = src[(i - 1 + src.length) % src.length];
                const nxt = src[(i + 1) % src.length];
                const hasPrev = cl || i > 0;
                const hasNext = cl || i < src.length - 1;
                let tx = 0;
                let ty = 0;
                if (hasPrev && hasNext) {
                    tx = 0.2 * (nxt.x - prev.x);
                    ty = 0.2 * (nxt.y - prev.y);
                } else if (hasNext) {
                    tx = 0.2 * (nxt.x - n.x);
                    ty = 0.2 * (nxt.y - n.y);
                } else if (hasPrev) {
                    tx = 0.2 * (n.x - prev.x);
                    ty = 0.2 * (n.y - prev.y);
                } else {
                    tx = 0.2;
                    ty = 0;
                }
                return {
                    x: n.x,
                    y: n.y,
                    handleOut: { x: tx, y: ty },
                    handleIn: { x: -tx, y: -ty },
                };
            });
        });

    const cornerAll = () =>
        onUpdate((draft) => {
            if (draft.type !== 'shape' || draft.shape !== 'path') return;
            const src = draft.nodes ?? [];
            draft.nodes = src.map((n) => ({ x: n.x, y: n.y }));
        });

    const reverse = () =>
        onUpdate((draft) => {
            if (draft.type !== 'shape' || draft.shape !== 'path') return;
            const src = draft.nodes ?? [];
            if (src.length < 2) return;
            const reversed: PathNode[] = [...src].reverse().map((n) => ({
                x: n.x,
                y: n.y,
                handleIn: n.handleOut,
                handleOut: n.handleIn,
            }));
            draft.nodes = reversed;
        });

    return (
        <>
            <div className="flex items-center justify-between">
                <Label>Closed path</Label>
                <SwitchToggle
                    checked={closed}
                    onChange={() =>
                        onUpdate((draft) => {
                            if (draft.type !== 'shape' || draft.shape !== 'path') return;
                            if (!draft.closed && (draft.nodes?.length ?? 0) < 3) return;
                            draft.closed = !draft.closed;
                        })
                    }
                    testId="prop-shape-path-closed"
                />
            </div>
            {!canClose && !closed && (
                <div className="text-[11px] text-muted-foreground/80">
                    Add at least 3 nodes to close the path.
                </div>
            )}
            <div className="grid grid-cols-3 gap-1">
                <button
                    type="button"
                    onClick={smoothAll}
                    disabled={nodes.length < 2}
                    data-testid="prop-shape-path-smooth-all"
                    className="rounded-md border border-border bg-card px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
                >
                    Smooth all
                </button>
                <button
                    type="button"
                    onClick={cornerAll}
                    disabled={nodes.length < 1}
                    data-testid="prop-shape-path-corner-all"
                    className="rounded-md border border-border bg-card px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
                >
                    Corner all
                </button>
                <button
                    type="button"
                    onClick={reverse}
                    disabled={nodes.length < 2}
                    data-testid="prop-shape-path-reverse"
                    className="rounded-md border border-border bg-card px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
                >
                    Reverse
                </button>
            </div>
            <div className="flex items-center justify-between">
                <Label>Nodes</Label>
                <span
                    className="font-mono text-[10px] text-muted-foreground"
                    data-testid="prop-shape-path-node-count"
                >
                    {nodes.length}
                </span>
            </div>
        </>
    );
}

function CornerRadiiGrid({ layer, onUpdate }: ShapeProps) {
    const base = layer.style?.radius ?? 0;
    const radii = layer.style?.radii ?? {};
    const setCorner = (
        corner: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight',
        v: number,
    ) =>
        onUpdate((draft) => {
            if (draft.type !== 'shape') return;
            draft.style = {
                ...draft.style,
                radii: { ...draft.style?.radii, [corner]: Math.max(0, v) },
            };
        });
    return (
        <div className="grid grid-cols-2 gap-1.5">
            <NumberField
                label="TL"
                value={radii.topLeft ?? base}
                min={0}
                onCommit={(v) => setCorner('topLeft', v)}
                testId="prop-shape-radius-tl"
            />
            <NumberField
                label="TR"
                value={radii.topRight ?? base}
                min={0}
                onCommit={(v) => setCorner('topRight', v)}
                testId="prop-shape-radius-tr"
            />
            <NumberField
                label="BL"
                value={radii.bottomLeft ?? base}
                min={0}
                onCommit={(v) => setCorner('bottomLeft', v)}
                testId="prop-shape-radius-bl"
            />
            <NumberField
                label="BR"
                value={radii.bottomRight ?? base}
                min={0}
                onCommit={(v) => setCorner('bottomRight', v)}
                testId="prop-shape-radius-br"
            />
        </div>
    );
}

function ShapeFillControls({ layer, onUpdate }: ShapeProps) {
    const fill: Color = layer.style?.fill ?? { type: 'solid', value: '#F0E8DE' };

    const setFill = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'shape') return;
            draft.style = { ...draft.style, fill: next };
        });

    return (
        <div className="space-y-2">
            {/* Gradient presets — same palette + interaction model as the
                background tool's Presets row. One-click swap to a tuned
                linear gradient; clicking again with the same active
                preset is a no-op. */}
            <div className="space-y-1.5" data-testid="prop-shape-presets-row">
                <Label>Presets</Label>
                <div className="flex flex-wrap gap-1.5">
                    {GRADIENT_PRESETS.map((p) => {
                        const fillStops = Array.isArray((fill as { stops?: unknown }).stops)
                            ? (fill as { stops: Array<{ offset: number; color: string }> }).stops
                            : [];
                        const active =
                            fill.type === 'linear' &&
                            fillStops.length === p.stops.length &&
                            fillStops.every(
                                (s, i) =>
                                    s.color.toUpperCase() === p.stops[i]!.color.toUpperCase() &&
                                    Math.abs(s.offset - p.stops[i]!.offset) < 0.01,
                            );
                        return (
                            <button
                                key={p.key}
                                type="button"
                                title={p.label}
                                aria-label={p.label}
                                data-testid={`prop-shape-preset-${p.key}`}
                                onClick={() =>
                                    setFill({
                                        type: 'linear',
                                        angle: p.angle,
                                        stops: p.stops.map((s) => ({ ...s })),
                                    })
                                }
                                className={cn(
                                    'h-7 w-7 rounded-md border transition-transform hover:scale-110',
                                    active ? 'border-accent ring-2 ring-accent/50' : 'border-border',
                                )}
                                style={{
                                    background: `linear-gradient(${p.angle}deg, ${p.stops
                                        .map((s) => `${s.color} ${Math.round(s.offset * 100)}%`)
                                        .join(', ')})`,
                                }}
                            />
                        );
                    })}
                </div>
            </div>

            <div className="space-y-1.5" data-testid="prop-shape-fill-type-row">
                <Label>Fill</Label>
                <Segmented
                    value={fill.type}
                    options={[
                        { value: 'solid', label: 'Solid' },
                        { value: 'linear', label: 'Linear' },
                        { value: 'radial', label: 'Radial' },
                    ]}
                    onChange={(nextType) => {
                        if (nextType === fill.type) return;
                        if (nextType === 'solid') {
                            setFill({
                                type: 'solid',
                                value: stopsFromFill(fill)[0]?.color ?? '#F0E8DE',
                            });
                        } else if (nextType === 'linear') {
                            setFill({
                                type: 'linear',
                                angle: fill.type === 'linear' ? fill.angle : 135,
                                stops: stopsFromFill(fill),
                            });
                        } else {
                            setFill({
                                type: 'radial',
                                cx: fill.type === 'radial' ? fill.cx : 0.5,
                                cy: fill.type === 'radial' ? fill.cy : 0.5,
                                radius: fill.type === 'radial' ? fill.radius : 0.5,
                                stops: stopsFromFill(fill),
                            });
                        }
                    }}
                    testIdPrefix="prop-shape-fill-type"
                />
            </div>

            {fill.type === 'solid' && (
                <ColorRow
                    label="Color"
                    value={fill.value}
                    onChange={(v) => setFill({ type: 'solid', value: v })}
                    testId="prop-shape-fill"
                />
            )}

            {fill.type === 'linear' && (
                <>
                    <SliderField
                        label="Angle"
                        value={Math.round(fill.angle)}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            setFill({ ...fill, angle: ((v % 360) + 360) % 360 })
                        }
                        testId="prop-shape-fill-angle"
                    />
                    <StopsEditor
                        stops={stopsFromFill(fill)}
                        onChange={(stops) => setFill({ ...fill, stops })}
                    />
                </>
            )}

            {fill.type === 'radial' && (
                <>
                    <SliderField
                        label="Center X"
                        value={Math.round(fill.cx * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, cx: v / 100 })}
                        testId="prop-shape-fill-cx"
                    />
                    <SliderField
                        label="Center Y"
                        value={Math.round(fill.cy * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, cy: v / 100 })}
                        testId="prop-shape-fill-cy"
                    />
                    <SliderField
                        label="Radius"
                        value={Math.round(fill.radius * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, radius: v / 100 })}
                        testId="prop-shape-fill-radius"
                    />
                    <StopsEditor
                        stops={stopsFromFill(fill)}
                        onChange={(stops) => setFill({ ...fill, stops })}
                    />
                </>
            )}
        </div>
    );
}

type ShapeStrokePaintProps = {
    layer: Extract<Layer, { type: 'shape' }>;
    onUpdate: LayerPropsProps['onUpdate'];
    /** Default stroke color when user first touches the picker on a layer with no stroke. */
    defaultColor?: string;
    /** Default stroke width when the stroke block doesn't have one yet. */
    defaultWidth?: number;
};

function ShapeStrokePaint({
    layer,
    onUpdate,
    defaultColor = '#0F172A',
    defaultWidth = 1,
}: ShapeStrokePaintProps) {
    const stroke: Color =
        strokeColorAsColor(layer.style?.stroke?.color) ??
        { type: 'solid', value: defaultColor };

    const setStroke = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'shape') return;
            const existing = draft.style?.stroke;
            draft.style = {
                ...draft.style,
                stroke: {
                    ...existing,
                    color: next,
                    width: existing?.width ?? defaultWidth,
                },
            };
        });

    return (
        <StrokePaintPicker
            value={stroke}
            onChange={setStroke}
            defaultColor={defaultColor}
            label="Stroke color"
            testIdPrefix="prop-shape-stroke"
        />
    );
}

type StrokePaintPickerProps = {
    value: Color;
    onChange: (next: Color) => void;
    /** Fallback color used when toggling to solid from an empty paint. */
    defaultColor?: string;
    /** Visible label above the type segmented control. */
    label?: string;
    /** Prefix for every data-testid rendered by this picker. */
    testIdPrefix: string;
};

function StrokePaintPicker({
    value,
    onChange,
    defaultColor = '#0F172A',
    label = 'Color',
    testIdPrefix,
}: StrokePaintPickerProps) {
    return (
        <div className="space-y-2">
            <div className="space-y-1.5" data-testid={`${testIdPrefix}-type-row`}>
                <Label>{label}</Label>
                <Segmented
                    value={value.type}
                    options={[
                        { value: 'solid', label: 'Solid' },
                        { value: 'linear', label: 'Linear' },
                        { value: 'radial', label: 'Radial' },
                    ]}
                    onChange={(nextType) => {
                        if (nextType === value.type) return;
                        if (nextType === 'solid') {
                            onChange({
                                type: 'solid',
                                value: stopsFromFill(value)[0]?.color ?? defaultColor,
                            });
                        } else if (nextType === 'linear') {
                            onChange({
                                type: 'linear',
                                angle: value.type === 'linear' ? value.angle : 135,
                                stops: stopsFromFill(value),
                            });
                        } else {
                            onChange({
                                type: 'radial',
                                cx: value.type === 'radial' ? value.cx : 0.5,
                                cy: value.type === 'radial' ? value.cy : 0.5,
                                radius: value.type === 'radial' ? value.radius : 0.5,
                                stops: stopsFromFill(value),
                            });
                        }
                    }}
                    testIdPrefix={`${testIdPrefix}-type`}
                />
            </div>

            {value.type === 'solid' && (
                <ColorRow
                    label="Color"
                    value={value.value}
                    onChange={(v) => onChange({ type: 'solid', value: v })}
                    testId={`${testIdPrefix}-solid`}
                />
            )}

            {value.type === 'linear' && (
                <>
                    <SliderField
                        label="Angle"
                        value={Math.round(value.angle)}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            onChange({ ...value, angle: ((v % 360) + 360) % 360 })
                        }
                        testId={`${testIdPrefix}-angle`}
                    />
                    <StopsEditor
                        stops={stopsFromFill(value)}
                        onChange={(stops) => onChange({ ...value, stops })}
                    />
                </>
            )}

            {value.type === 'radial' && (
                <>
                    <SliderField
                        label="Center X"
                        value={Math.round(value.cx * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => onChange({ ...value, cx: v / 100 })}
                        testId={`${testIdPrefix}-cx`}
                    />
                    <SliderField
                        label="Center Y"
                        value={Math.round(value.cy * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => onChange({ ...value, cy: v / 100 })}
                        testId={`${testIdPrefix}-cy`}
                    />
                    <SliderField
                        label="Radius"
                        value={Math.round(value.radius * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => onChange({ ...value, radius: v / 100 })}
                        testId={`${testIdPrefix}-radius`}
                    />
                    <StopsEditor
                        stops={stopsFromFill(value)}
                        onChange={(stops) => onChange({ ...value, stops })}
                    />
                </>
            )}
        </div>
    );
}

/* -------------------------------------------------------------------------- */
/* Background                                                                 */
/* -------------------------------------------------------------------------- */

type BgProps = {
    layer: Extract<Layer, { type: 'background' }>;
    projectId: number;
    onUpdate: LayerPropsProps['onUpdate'];
    caps?: Set<SectionKey>;
};

/**
 * Soft / pastel gradient palette for background presets. Tuned for app-
 * store screenshot mockups where the screen + text are the focal points
 * and the background should support, not compete. Previous palette was
 * saturated electric / sunset / dark dusk gradients that visually
 * overpowered the foreground content.
 */
const GRADIENT_PRESETS: Array<{
    key: string;
    label: string;
    angle: number;
    stops: Array<{ offset: number; color: string }>;
}> = [
    {
        key: 'peach',
        label: 'Peach',
        angle: 135,
        stops: [
            { offset: 0, color: '#FFE5D9' },
            { offset: 1, color: '#FFB89E' },
        ],
    },
    {
        key: 'blush',
        label: 'Blush',
        angle: 135,
        stops: [
            { offset: 0, color: '#FCE7F3' },
            { offset: 1, color: '#FBCFE8' },
        ],
    },
    {
        key: 'lavender',
        label: 'Lavender',
        angle: 135,
        stops: [
            { offset: 0, color: '#E8E1F5' },
            { offset: 1, color: '#C9B6E4' },
        ],
    },
    {
        key: 'sky',
        label: 'Sky',
        angle: 180,
        stops: [
            { offset: 0, color: '#E0F2FE' },
            { offset: 1, color: '#BAE6FD' },
        ],
    },
    {
        key: 'sage',
        label: 'Sage',
        angle: 135,
        stops: [
            { offset: 0, color: '#E8F1E4' },
            { offset: 1, color: '#C7E0B8' },
        ],
    },
    {
        key: 'honey',
        label: 'Honey',
        angle: 135,
        stops: [
            { offset: 0, color: '#FEF3C7' },
            { offset: 1, color: '#FDE68A' },
        ],
    },
    {
        key: 'latte',
        label: 'Latte',
        angle: 135,
        stops: [
            { offset: 0, color: '#F5E6D3' },
            { offset: 1, color: '#D4B896' },
        ],
    },
    {
        key: 'mist',
        label: 'Mist',
        angle: 135,
        stops: [
            { offset: 0, color: '#E2E8F0' },
            { offset: 1, color: '#CBD5E1' },
        ],
    },
];

function BackgroundControls({ layer, projectId, onUpdate }: BgProps) {
    const fill = layer.fill;
    const setFill = (next: Color) =>
        onUpdate((draft) => {
            if (draft.type !== 'background') return;
            draft.fill = next;
        });

    return (
        <Section title="Background">
            <div className="space-y-1.5" data-testid="prop-bg-presets-row">
                <Label>Presets</Label>
                <div className="flex flex-wrap gap-1.5">
                    {GRADIENT_PRESETS.map((p) => {
                        const fillStops = Array.isArray((fill as { stops?: unknown }).stops)
                            ? (fill as { stops: Array<{ offset: number; color: string }> }).stops
                            : [];
                        const active =
                            fill.type === 'linear' &&
                            fillStops.length === p.stops.length &&
                            fillStops.every(
                                (s, i) =>
                                    s.color.toUpperCase() === p.stops[i]!.color.toUpperCase() &&
                                    Math.abs(s.offset - p.stops[i]!.offset) < 0.01,
                            );
                        return (
                            <button
                                key={p.key}
                                type="button"
                                title={p.label}
                                aria-label={p.label}
                                data-testid={`prop-bg-preset-${p.key}`}
                                onClick={() =>
                                    setFill({
                                        type: 'linear',
                                        angle: p.angle,
                                        stops: p.stops.map((s) => ({ ...s })),
                                    })
                                }
                                className={cn(
                                    'h-7 w-7 rounded-md border transition-transform hover:scale-110',
                                    active ? 'border-accent ring-2 ring-accent/50' : 'border-border',
                                )}
                                style={{
                                    background: `linear-gradient(${p.angle}deg, ${p.stops
                                        .map((s) => `${s.color} ${Math.round(s.offset * 100)}%`)
                                        .join(', ')})`,
                                }}
                            />
                        );
                    })}
                </div>
            </div>

            <div className="space-y-1.5" data-testid="prop-bg-type-row">
                <Label>Type</Label>
                <Segmented
                    value={fill.type}
                    options={[
                        { value: 'solid', label: 'Solid' },
                        { value: 'linear', label: 'Linear' },
                        { value: 'radial', label: 'Radial' },
                    ]}
                    onChange={(nextType) => {
                        if (nextType === fill.type) return;
                        if (nextType === 'solid') {
                            setFill({
                                type: 'solid',
                                value: stopsFromFill(fill)[0]?.color ?? '#0052FF',
                            });
                        } else if (nextType === 'linear') {
                            setFill({
                                type: 'linear',
                                angle: fill.type === 'linear' ? fill.angle : 135,
                                stops: stopsFromFill(fill),
                            });
                        } else {
                            setFill({
                                type: 'radial',
                                cx: fill.type === 'radial' ? fill.cx : 0.5,
                                cy: fill.type === 'radial' ? fill.cy : 0.5,
                                radius: fill.type === 'radial' ? fill.radius : 0.5,
                                stops: stopsFromFill(fill),
                            });
                        }
                    }}
                    testIdPrefix="prop-bg-type"
                />
            </div>

            {fill.type === 'solid' && (
                <ColorRow
                    label="Color"
                    value={fill.value}
                    onChange={(v) => setFill({ type: 'solid', value: v })}
                    testId="prop-bg-fill"
                />
            )}

            {fill.type === 'linear' && (
                <>
                    <SliderField
                        label="Angle"
                        value={Math.round(fill.angle)}
                        min={0}
                        max={360}
                        suffix="°"
                        onChange={(v) =>
                            setFill({
                                ...fill,
                                angle: ((v % 360) + 360) % 360,
                            })
                        }
                        testId="prop-bg-angle"
                    />
                    <StopsEditor
                        stops={stopsFromFill(fill)}
                        onChange={(stops) => setFill({ ...fill, stops })}
                    />
                </>
            )}

            {fill.type === 'radial' && (
                <>
                    <SliderField
                        label="Center X"
                        value={Math.round(fill.cx * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, cx: v / 100 })}
                        testId="prop-bg-cx"
                    />
                    <SliderField
                        label="Center Y"
                        value={Math.round(fill.cy * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, cy: v / 100 })}
                        testId="prop-bg-cy"
                    />
                    <SliderField
                        label="Radius"
                        value={Math.round(fill.radius * 100)}
                        min={0}
                        max={100}
                        onChange={(v) => setFill({ ...fill, radius: v / 100 })}
                        testId="prop-bg-radius"
                    />
                    <StopsEditor
                        stops={stopsFromFill(fill)}
                        onChange={(stops) => setFill({ ...fill, stops })}
                    />
                </>
            )}

            <BackgroundImageControls layer={layer} projectId={projectId} onUpdate={onUpdate} />
        </Section>
    );
}

function BackgroundImageControls({ layer, projectId, onUpdate }: BgProps) {
    const image = layer.image;
    const fileInputRef = useRef<HTMLInputElement | null>(null);
    const [uploading, setUploading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    const onPick = () => fileInputRef.current?.click();
    const onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        e.target.value = '';
        if (!file) return;
        setUploading(true);
        setError(null);
        try {
            const media = await uploadMedia(projectId, file);
            onUpdate((draft) => {
                if (draft.type !== 'background') return;
                // Replace: preserve every existing image property (opacity,
                // blur, overlay, zoom, offsetX/Y, fit) — only swap the
                // storagePath. Pre-fix, every replace clobbered the user's
                // tinting / blur / pan-zoom adjustments back to defaults.
                draft.image = {
                    ...(draft.image ?? { fit: 'cover' as const }),
                    storagePath: media.storagePath,
                };
            });
        } catch (err) {
            setError(err instanceof Error ? err.message : 'Upload failed');
        } finally {
            setUploading(false);
        }
    };
    const onRemove = () =>
        onUpdate((draft) => {
            if (draft.type !== 'background') return;
            delete draft.image;
        });
    const setFit = (fit: 'cover' | 'contain' | 'tile') =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            draft.image.fit = fit;
        });
    const setBlur = (blur: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            if (blur <= 0) delete draft.image.blur;
            else draft.image.blur = blur;
        });
    const setOpacity = (opacity: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            // Drop the field when full opacity — keeps storage clean +
            // keeps the v3 → newer migrator backward-compat (undefined === 1).
            if (opacity >= 1) delete draft.image.opacity;
            else draft.image.opacity = opacity;
        });
    const toggleOverlay = () =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            if (draft.image.overlay) {
                delete draft.image.overlay;
            } else {
                draft.image.overlay = { color: '#000000', opacity: 0.3 };
            }
        });
    const setOverlay = (patch: Partial<{ color: string; opacity: number }>) =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            const base = draft.image.overlay ?? { color: '#000000', opacity: 0.3 };
            draft.image.overlay = { ...base, ...patch };
        });
    const toggleTint = () =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            if (draft.image.tint) {
                delete draft.image.tint;
            } else {
                draft.image.tint = { color: '#5B7CFA', amount: 1 };
            }
        });
    const setTint = (patch: Partial<{ color: string; amount: number }>) =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            const base = draft.image.tint ?? { color: '#5B7CFA', amount: 1 };
            draft.image.tint = { ...base, ...patch };
        });
    const setZoom = (zoom: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            if (Math.abs(zoom - 1) < 0.001) delete draft.image.zoom;
            else draft.image.zoom = zoom;
        });
    const setOffset = (axis: 'offsetX' | 'offsetY', value: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            if (Math.abs(value) < 0.001) delete draft.image[axis];
            else draft.image[axis] = value;
        });
    const resetTransform = () =>
        onUpdate((draft) => {
            if (draft.type !== 'background' || !draft.image) return;
            delete draft.image.zoom;
            delete draft.image.offsetX;
            delete draft.image.offsetY;
        });

    const blur = image?.blur ?? 0;
    const opacity = image?.opacity ?? 1;
    const overlay = image?.overlay;
    const tint = image?.tint;
    const tileMode = image?.fit === 'tile';
    const zoom = image?.zoom ?? 1;
    const offsetX = image?.offsetX ?? 0;
    const offsetY = image?.offsetY ?? 0;
    const hasTransform =
        Math.abs(zoom - 1) > 0.001 ||
        Math.abs(offsetX) > 0.001 ||
        Math.abs(offsetY) > 0.001;

    return (
        <div className="space-y-1.5" data-testid="prop-bg-image-row">
            <Label className="flex items-center justify-between">
                <span>Image</span>
                {image && (
                    <button
                        type="button"
                        onClick={onRemove}
                        data-testid="prop-bg-image-remove"
                        aria-label="Remove background image"
                        className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
                    >
                        <Trash2 className="h-3.5 w-3.5" strokeWidth={2} />
                    </button>
                )}
            </Label>
            <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={onFile}
                data-testid="prop-bg-image-input"
            />
            {!image ? (
                <button
                    type="button"
                    onClick={onPick}
                    disabled={uploading}
                    data-testid="prop-bg-image-upload"
                    className="flex h-16 w-full items-center justify-center gap-2 rounded-md border border-dashed border-border bg-muted/30 text-xs text-muted-foreground transition-colors hover:border-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-60"
                >
                    <Upload className="h-4 w-4" strokeWidth={2} />
                    {uploading ? 'Uploading…' : 'Upload image'}
                </button>
            ) : (
                <div className="space-y-2">
                    <button
                        type="button"
                        onClick={onPick}
                        disabled={uploading}
                        data-testid="prop-bg-image-replace"
                        className="relative flex h-16 w-full items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30 transition-colors hover:border-accent disabled:pointer-events-none disabled:opacity-60"
                    >
                        <img
                            src={mediaUrl(image.storagePath)}
                            alt=""
                            className="h-full w-full object-cover"
                        />
                        <span className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 text-[11px] font-medium text-white opacity-0 transition-opacity hover:opacity-100">
                            <ImageIcon className="h-3.5 w-3.5" strokeWidth={2} />
                            {uploading ? 'Uploading…' : 'Replace'}
                        </span>
                    </button>
                    <Segmented
                        value={image.fit}
                        options={[
                            { value: 'cover', label: 'Cover' },
                            { value: 'contain', label: 'Contain' },
                            { value: 'tile', label: 'Tile' },
                        ]}
                        onChange={(v) => setFit(v as 'cover' | 'contain' | 'tile')}
                        testIdPrefix="prop-bg-image-fit"
                    />
                    <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                        <div className="flex items-center justify-between">
                            <Label>Position</Label>
                            <button
                                type="button"
                                onClick={resetTransform}
                                disabled={!hasTransform}
                                data-testid="prop-bg-image-transform-reset"
                                className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                            >
                                Reset
                            </button>
                        </div>
                        <SliderField
                            label="Zoom"
                            value={Math.round(zoom * 100)}
                            min={50}
                            max={300}
                            suffix="%"
                            onChange={(v) => setZoom(v / 100)}
                            testId="prop-bg-image-zoom"
                        />
                        <SliderField
                            label="Offset X"
                            value={Math.round(offsetX * 100)}
                            min={-100}
                            max={100}
                            suffix="%"
                            onChange={(v) => setOffset('offsetX', v / 100)}
                            testId="prop-bg-image-offset-x"
                        />
                        <SliderField
                            label="Offset Y"
                            value={Math.round(offsetY * 100)}
                            min={-100}
                            max={100}
                            suffix="%"
                            onChange={(v) => setOffset('offsetY', v / 100)}
                            testId="prop-bg-image-offset-y"
                        />
                    </div>
                    <SliderField
                        label="Opacity"
                        value={Math.round(opacity * 100)}
                        min={0}
                        max={100}
                        suffix="%"
                        onChange={(v) => setOpacity(v / 100)}
                        testId="prop-bg-image-opacity"
                    />
                    {!tileMode && (
                        <SliderField
                            label="Blur"
                            value={Math.round(blur)}
                            min={0}
                            max={40}
                            suffix="px"
                            onChange={setBlur}
                            testId="prop-bg-image-blur"
                        />
                    )}
                    <div className="space-y-2">
                        <div className="flex items-center justify-between">
                            <Label>Overlay</Label>
                            <SwitchToggle
                                checked={!!overlay}
                                onChange={toggleOverlay}
                                testId="prop-bg-image-overlay-toggle"
                            />
                        </div>
                        {overlay && (
                            <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                                <ColorRow
                                    label="Color"
                                    value={overlay.color}
                                    onChange={(v) => setOverlay({ color: v })}
                                    testId="prop-bg-image-overlay-color"
                                />
                                <SliderField
                                    label="Opacity"
                                    value={Math.round(overlay.opacity * 100)}
                                    min={0}
                                    max={100}
                                    onChange={(v) => setOverlay({ opacity: v / 100 })}
                                    testId="prop-bg-image-overlay-opacity"
                                />
                            </div>
                        )}
                    </div>
                    {!tileMode && (
                        <div className="space-y-2">
                            <div className="flex items-center justify-between">
                                <Label>Tint</Label>
                                <SwitchToggle
                                    checked={!!tint}
                                    onChange={toggleTint}
                                    testId="prop-bg-image-tint-toggle"
                                />
                            </div>
                            {tint && (
                                <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                                    <ColorRow
                                        label="Color"
                                        value={tint.color}
                                        onChange={(v) => setTint({ color: v })}
                                        testId="prop-bg-image-tint-color"
                                    />
                                    <SliderField
                                        label="Strength"
                                        value={Math.round(tint.amount * 100)}
                                        min={0}
                                        max={100}
                                        suffix="%"
                                        onChange={(v) => setTint({ amount: v / 100 })}
                                        testId="prop-bg-image-tint-amount"
                                    />
                                </div>
                            )}
                        </div>
                    )}
                </div>
            )}
            {error && (
                <p className="text-[10px] text-destructive" data-testid="prop-bg-image-error">
                    {error}
                </p>
            )}
        </div>
    );
}

function colorAsSolidString(
    color: string | Color | undefined,
    fallback: string,
): string {
    if (color === undefined) return fallback;
    if (typeof color === 'string') return color;
    if (color.type === 'solid') return color.value;
    const stops = Array.isArray(color.stops) ? color.stops : [];
    return stops[0]?.color ?? fallback;
}

function stopsFromFill(fill: Color): Array<{ offset: number; color: string }> {
    if (fill.type === 'solid') {
        return [
            { offset: 0, color: fill.value },
            { offset: 1, color: fill.value },
        ];
    }
    // Defensive: a partial deepMerge of a Color override (e.g. type swapped
    // without stops) can land here with non-iterable stops.
    const existing = Array.isArray(fill.stops) ? fill.stops : [];
    return existing.length > 0
        ? existing
        : [
              { offset: 0, color: '#0052FF' },
              { offset: 1, color: '#4D7CFF' },
          ];
}

type Stop = { offset: number; color: string };

function StopsEditor({
    stops,
    onChange,
}: {
    stops: Stop[];
    onChange: (stops: Stop[]) => void;
}) {
    const setStop = (i: number, patch: Partial<Stop>) => {
        const next = stops.map((s, idx) => (idx === i ? { ...s, ...patch } : s));
        onChange(next);
    };
    const addStop = () => {
        const lastOffset = stops[stops.length - 1]?.offset ?? 0;
        onChange([
            ...stops,
            {
                offset: Math.min(1, +(lastOffset + 0.25).toFixed(3)),
                color: stops[stops.length - 1]?.color ?? '#FFFFFF',
            },
        ]);
    };
    const removeStop = (i: number) => {
        if (stops.length <= 2) return;
        onChange(stops.filter((_, idx) => idx !== i));
    };

    return (
        <div className="space-y-1.5" data-testid="prop-bg-stops">
            <div className="flex items-center justify-between">
                <Label>Stops</Label>
                <button
                    type="button"
                    onClick={addStop}
                    data-testid="prop-bg-stop-add"
                    aria-label="Add stop"
                    className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
                >
                    <Plus className="h-3.5 w-3.5" strokeWidth={2} />
                </button>
            </div>
            <div className="space-y-1.5">
                {stops.map((stop, i) => (
                    <div
                        key={i}
                        className="flex items-center gap-2"
                        data-testid={`prop-bg-stop-${i}`}
                    >
                        <input
                            type="color"
                            value={stop.color}
                            onChange={(e) => setStop(i, { color: e.target.value })}
                            className="h-8 w-10 shrink-0 cursor-pointer rounded border border-border bg-card p-1"
                            data-testid={`prop-bg-stop-${i}-color`}
                        />
                        <input
                            type="range"
                            min={0}
                            max={100}
                            step={1}
                            value={Math.round(stop.offset * 100)}
                            onChange={(e) => {
                                const n = Number(e.target.value);
                                if (Number.isFinite(n)) {
                                    setStop(i, { offset: n / 100 });
                                }
                            }}
                            data-testid={`prop-bg-stop-${i}-offset`}
                            className="h-2 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-accent"
                        />
                        <span className="w-8 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
                            {Math.round(stop.offset * 100)}%
                        </span>
                        <button
                            type="button"
                            onClick={() => removeStop(i)}
                            disabled={stops.length <= 2}
                            data-testid={`prop-bg-stop-${i}-remove`}
                            aria-label="Remove stop"
                            className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-40"
                        >
                            <Trash2 className="h-3.5 w-3.5" strokeWidth={2} />
                        </button>
                    </div>
                ))}
            </div>
        </div>
    );
}

/* -------------------------------------------------------------------------- */
/* Image                                                                      */
/* -------------------------------------------------------------------------- */

type ImgProps = {
    layer: Extract<Layer, { type: 'image' }>;
    projectId: number;
    onUpdate: LayerPropsProps['onUpdate'];
    caps?: Set<SectionKey>;
};

function ImageControls({ layer, projectId, onUpdate, caps: capsProp }: ImgProps) {
    const caps = capsProp ?? capabilitiesFor(layer);
    const showImageTransform = caps.has('imageTransform');
    const showCrop = caps.has('crop');
    const showFilters = caps.has('filters');
    const showStrokeShadowRadius = caps.has('stroke') || caps.has('shadow');
    const cropModeLayerId = useEditor((s) => s.cropModeLayerId);
    const enterCropMode = useEditor((s) => s.enterCropMode);
    const exitCropMode = useEditor((s) => s.exitCropMode);
    const isCropping = cropModeLayerId === layer.id;

    const replaceInputRef = useRef<HTMLInputElement | null>(null);
    const [replaceUploading, setReplaceUploading] = useState(false);
    const replaceToast = useToast();
    const onReplaceFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        e.target.value = '';
        if (!file) return;
        setReplaceUploading(true);
        const toastId = replaceToast.show({
            kind: 'loading',
            message: `Uploading ${file.name}`,
            progress: 0,
        });
        try {
            const media = await uploadMedia(projectId, file, {
                onProgress: (fraction) => replaceToast.update(toastId, { progress: fraction }),
            });
            onUpdate((draft) => {
                if (draft.type !== 'image') return;
                draft.storagePath = media.storagePath;
            });
            replaceToast.dismiss(toastId);
            replaceToast.success(`Uploaded ${file.name}`);
        } catch (err) {
            replaceToast.dismiss(toastId);
            replaceToast.error(err instanceof Error ? err.message : 'Upload failed');
        } finally {
            setReplaceUploading(false);
        }
    };
    const radius = layer.style?.radius ?? 0;
    const strokeW = layer.style?.stroke?.width ?? 0;
    const strokeDashValue = layer.style?.stroke?.dash ?? 'solid';

    // Pan/zoom are only meaningful for Cover/Contain — Fill stretches to the
    // rect, so offset/zoom on a Fill image would be ignored at render time.
    const canPanZoom = layer.fit === 'cover' || layer.fit === 'contain';
    const zoom = layer.zoom ?? 1;
    const offsetX = layer.offsetX ?? 0;
    const offsetY = layer.offsetY ?? 0;
    const flipX = layer.flipX === true;
    const flipY = layer.flipY === true;
    const skewX = layer.skewX ?? 0;
    const skewY = layer.skewY ?? 0;
    const tiltY = layer.tiltY ?? 0;
    const tiltX = layer.tiltX ?? 0;
    const hasTransform =
        Math.abs(zoom - 1) > 0.001 ||
        Math.abs(offsetX) > 0.001 ||
        Math.abs(offsetY) > 0.001 ||
        Math.abs(skewX) > 0.01 ||
        Math.abs(skewY) > 0.01 ||
        Math.abs(tiltY) > 0.01 ||
        Math.abs(tiltX) > 0.01 ||
        flipX ||
        flipY;

    const setZoom = (v: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            if (Math.abs(v - 1) < 0.001) delete draft.zoom;
            else draft.zoom = v;
        });
    const setOffset = (axis: 'offsetX' | 'offsetY', v: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            if (Math.abs(v) < 0.001) delete draft[axis];
            else draft[axis] = v;
        });
    const setSkew = (axis: 'skewX' | 'skewY', v: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            if (Math.abs(v) < 0.01) delete draft[axis];
            else draft[axis] = v;
        });
    const setTilt = (axis: 'tiltX' | 'tiltY', v: number) =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            if (Math.abs(v) < 0.01) delete draft[axis];
            else draft[axis] = v;
        });
    const toggleFlip = (axis: 'flipX' | 'flipY') =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            if (draft[axis]) delete draft[axis];
            else draft[axis] = true;
        });
    const rotateBy = (delta: number) =>
        onUpdate((draft) => {
            const current = draft.rotation ?? 0;
            let next = (current + delta) % 360;
            if (next < 0) next += 360;
            if (next > 180) next -= 360;
            if (Math.abs(next) < 0.001) delete draft.rotation;
            else draft.rotation = next;
        });
    const resetTransform = () =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            delete draft.zoom;
            delete draft.offsetX;
            delete draft.offsetY;
            delete draft.skewX;
            delete draft.skewY;
            delete draft.tiltY;
            delete draft.tiltX;
            delete draft.flipX;
            delete draft.flipY;
        });

    const crop: ImageCrop = layer.crop ?? {};
    const hasCrop =
        (crop.top ?? 0) > 0
        || (crop.right ?? 0) > 0
        || (crop.bottom ?? 0) > 0
        || (crop.left ?? 0) > 0;

    const adjust: ImageAdjust = layer.adjust ?? {};
    const tintColor = adjust.tint?.color ?? '#0052FF';
    const tintAmount = adjust.tint?.amount ?? 0;
    const hasAdjust =
        (adjust.brightness ?? 0) !== 0 ||
        (adjust.contrast ?? 0) !== 0 ||
        (adjust.saturation ?? 0) !== 0 ||
        (adjust.hue ?? 0) !== 0 ||
        (adjust.blur ?? 0) > 0 ||
        adjust.grayscale === true ||
        adjust.invert === true ||
        tintAmount > 0;

    const setAdjust = (patch: Partial<ImageAdjust>) =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            const next: ImageAdjust = { ...(draft.adjust ?? {}), ...patch };
            // Strip defaults so the stored layer stays minimal — matters for
            // auto-save payload size and for "is this image untouched?" checks.
            (Object.keys(next) as Array<keyof ImageAdjust>).forEach((k) => {
                const v = next[k];
                if (v == null) delete next[k];
                else if (typeof v === 'number' && v === 0) delete next[k];
                else if (typeof v === 'boolean' && v === false) delete next[k];
                else if (k === 'tint' && (v as { amount: number }).amount <= 0)
                    delete next[k];
            });
            if (Object.keys(next).length === 0) delete draft.adjust;
            else draft.adjust = next;
        });
    const resetAdjust = () =>
        onUpdate((draft) => {
            if (draft.type !== 'image') return;
            delete draft.adjust;
        });

    return (
        <Section title="Image">
            <div className="space-y-1.5">
                <Label>Source</Label>
                <input
                    ref={replaceInputRef}
                    type="file"
                    accept="image/*"
                    className="hidden"
                    onChange={onReplaceFile}
                    data-testid="prop-image-replace-input"
                />
                {/* Thumbnail preview doubles as the Replace target — same
                    pattern as the background-image picker. Click the
                    thumbnail to open the file dialog; hover reveals the
                    "Replace" overlay so the affordance stays discoverable
                    even when the thumbnail itself is busy / dark. */}
                <button
                    type="button"
                    onClick={() => replaceInputRef.current?.click()}
                    disabled={replaceUploading}
                    data-testid="prop-image-replace-button"
                    className="group relative flex h-20 w-full items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30 transition-colors hover:border-accent disabled:pointer-events-none disabled:opacity-60"
                >
                    {layer.storagePath ? (
                        <img
                            src={mediaUrl(layer.storagePath)}
                            alt=""
                            className="h-full w-full object-contain"
                            data-testid="prop-image-preview"
                        />
                    ) : (
                        <span className="flex items-center gap-1 text-[11px] text-muted-foreground">
                            <ImageIcon className="h-3.5 w-3.5" strokeWidth={2} />
                            No image
                        </span>
                    )}
                    <span className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 text-[11px] font-medium text-white opacity-0 transition-opacity group-hover:opacity-100">
                        <ImageIcon className="h-3.5 w-3.5" strokeWidth={2} />
                        {replaceUploading ? 'Uploading…' : 'Replace'}
                    </span>
                </button>
            </div>

            {showCrop && (
            <div className="rounded-md border border-border bg-background/40 p-2">
                <div className="flex items-center justify-between gap-2">
                    <Label>Crop</Label>
                    <button
                        type="button"
                        onClick={() =>
                            isCropping ? exitCropMode() : enterCropMode(layer.id)
                        }
                        data-testid="prop-image-crop-mode"
                        data-active={isCropping ? 'true' : 'false'}
                        aria-pressed={isCropping}
                        className={cn(
                            'inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium transition-colors',
                            isCropping
                                ? 'border-accent bg-accent/10 text-accent'
                                : hasCrop
                                  ? 'border-border bg-card text-foreground hover:text-foreground'
                                  : 'border-border bg-card text-muted-foreground hover:text-foreground',
                        )}
                    >
                        {isCropping ? 'Done' : hasCrop ? 'Edit crop' : 'Crop'}
                    </button>
                </div>
            </div>
            )}

            <div className="space-y-1.5" data-testid="prop-image-fit-row">
                <Label>Fit</Label>
                <Segmented
                    value={layer.fit}
                    options={[
                        { value: 'cover', label: 'Cover' },
                        { value: 'contain', label: 'Contain' },
                        { value: 'fill', label: 'Fill' },
                    ]}
                    onChange={(mode) =>
                        onUpdate((draft) => {
                            if (draft.type !== 'image') return;
                            draft.fit = mode as typeof layer.fit;
                        })
                    }
                    testIdPrefix="prop-image-fit"
                />
            </div>

            {showImageTransform && (
            <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                <div className="flex items-center justify-between">
                    <Label>Transform</Label>
                    <button
                        type="button"
                        onClick={resetTransform}
                        disabled={!hasTransform}
                        data-testid="prop-image-transform-reset"
                        className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                    >
                        Reset
                    </button>
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Flip H</span>
                    <SwitchToggle
                        checked={flipX}
                        onChange={() => toggleFlip('flipX')}
                        testId="prop-image-flip-x"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Flip V</span>
                    <SwitchToggle
                        checked={flipY}
                        onChange={() => toggleFlip('flipY')}
                        testId="prop-image-flip-y"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Rotate 90°</span>
                    <div className="flex items-center gap-1">
                        <button
                            type="button"
                            onClick={() => rotateBy(-90)}
                            data-testid="prop-image-rotate-ccw"
                            aria-label="Rotate 90° counter-clockwise"
                            className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:text-foreground"
                        >
                            <svg
                                width="14"
                                height="14"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="2"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                aria-hidden="true"
                            >
                                <path d="M3 12a9 9 0 1 0 3-6.7" />
                                <polyline points="3 4 3 10 9 10" />
                            </svg>
                        </button>
                        <button
                            type="button"
                            onClick={() => rotateBy(90)}
                            data-testid="prop-image-rotate-cw"
                            aria-label="Rotate 90° clockwise"
                            className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:text-foreground"
                        >
                            <svg
                                width="14"
                                height="14"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="2"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                aria-hidden="true"
                            >
                                <path d="M21 12a9 9 0 1 1-3-6.7" />
                                <polyline points="21 4 21 10 15 10" />
                            </svg>
                        </button>
                    </div>
                </div>
                {canPanZoom && (
                    <>
                        <SliderField
                            label="Zoom"
                            value={Math.round(zoom * 100)}
                            min={50}
                            max={300}
                            suffix="%"
                            onChange={(v) => setZoom(v / 100)}
                            testId="prop-image-zoom"
                        />
                        <SliderField
                            label="Offset X"
                            value={Math.round(offsetX * 100)}
                            min={-100}
                            max={100}
                            suffix="%"
                            onChange={(v) => setOffset('offsetX', v / 100)}
                            testId="prop-image-offset-x"
                        />
                        <SliderField
                            label="Offset Y"
                            value={Math.round(offsetY * 100)}
                            min={-100}
                            max={100}
                            suffix="%"
                            onChange={(v) => setOffset('offsetY', v / 100)}
                            testId="prop-image-offset-y"
                        />
                    </>
                )}
                <SliderField
                    label="Skew X"
                    value={Math.round(skewX)}
                    min={-45}
                    max={45}
                    suffix="°"
                    onChange={(v) => setSkew('skewX', v)}
                    testId="prop-image-skew-x"
                />
                <SliderField
                    label="Skew Y"
                    value={Math.round(skewY)}
                    min={-45}
                    max={45}
                    suffix="°"
                    onChange={(v) => setSkew('skewY', v)}
                    testId="prop-image-skew-y"
                />
                <SliderField
                    label="3D Tilt Y"
                    value={Math.round(tiltY)}
                    min={-45}
                    max={45}
                    suffix="°"
                    onChange={(v) => setTilt('tiltY', v)}
                    testId="prop-image-tilt-y"
                />
                <SliderField
                    label="3D Tilt X"
                    value={Math.round(tiltX)}
                    min={-45}
                    max={45}
                    suffix="°"
                    onChange={(v) => setTilt('tiltX', v)}
                    testId="prop-image-tilt-x"
                />
            </div>
            )}

            {showFilters && (
            <div className="space-y-2 rounded-md border border-border bg-background/40 p-2">
                <div className="flex items-center justify-between">
                    <Label>Adjust</Label>
                    <button
                        type="button"
                        onClick={resetAdjust}
                        disabled={!hasAdjust}
                        data-testid="prop-image-adjust-reset"
                        className="text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                    >
                        Reset
                    </button>
                </div>
                {(Math.abs(tiltX) > 0.01 || Math.abs(tiltY) > 0.01) && hasAdjust && (
                    <p
                        className="text-[11px] font-medium text-warning"
                        data-testid="prop-image-adjust-tilt-warning"
                    >
                        Filters skip while 3D tilt is active
                    </p>
                )}
                <SliderField
                    label="Brightness"
                    value={Math.round((adjust.brightness ?? 0) * 100)}
                    min={-100}
                    max={100}
                    suffix="%"
                    onChange={(v) => setAdjust({ brightness: v / 100 })}
                    testId="prop-image-adjust-brightness"
                />
                <SliderField
                    label="Contrast"
                    value={adjust.contrast ?? 0}
                    min={-100}
                    max={100}
                    onChange={(v) => setAdjust({ contrast: v })}
                    testId="prop-image-adjust-contrast"
                />
                <SliderField
                    label="Saturation"
                    value={Math.round((adjust.saturation ?? 0) * 100)}
                    min={-100}
                    max={100}
                    suffix="%"
                    onChange={(v) => setAdjust({ saturation: v / 100 })}
                    testId="prop-image-adjust-saturation"
                />
                <SliderField
                    label="Hue"
                    value={Math.round(adjust.hue ?? 0)}
                    min={-180}
                    max={180}
                    suffix="°"
                    onChange={(v) => setAdjust({ hue: v })}
                    testId="prop-image-adjust-hue"
                />
                <SliderField
                    label="Blur"
                    value={Math.round(adjust.blur ?? 0)}
                    min={0}
                    max={40}
                    suffix="px"
                    onChange={(v) => setAdjust({ blur: v })}
                    testId="prop-image-adjust-blur"
                />
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Grayscale</span>
                    <SwitchToggle
                        checked={adjust.grayscale === true}
                        onChange={() =>
                            setAdjust({ grayscale: !(adjust.grayscale === true) })
                        }
                        testId="prop-image-adjust-grayscale"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Invert</span>
                    <SwitchToggle
                        checked={adjust.invert === true}
                        onChange={() => setAdjust({ invert: !(adjust.invert === true) })}
                        testId="prop-image-adjust-invert"
                    />
                </div>
                <div className="flex items-center justify-between">
                    <span className="text-xs text-muted-foreground">Tint color</span>
                    <input
                        type="color"
                        value={tintColor}
                        onChange={(e) =>
                            setAdjust({
                                tint: {
                                    color: e.target.value,
                                    amount: tintAmount > 0 ? tintAmount : 0.3,
                                },
                            })
                        }
                        data-testid="prop-image-adjust-tint-color"
                        className="h-7 w-10 cursor-pointer rounded-md border border-border bg-background p-0"
                    />
                </div>
                <SliderField
                    label="Tint amount"
                    value={Math.round(tintAmount * 100)}
                    min={0}
                    max={100}
                    suffix="%"
                    onChange={(v) =>
                        setAdjust({ tint: { color: tintColor, amount: v / 100 } })
                    }
                    testId="prop-image-adjust-tint-amount"
                />
            </div>
            )}

            {showStrokeShadowRadius && (
                <>
                    <SliderField
                        label="Corner radius"
                        value={Math.round(radius)}
                        min={0}
                        max={200}
                        suffix="px"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'image') return;
                                draft.style = {
                                    ...draft.style,
                                    radius: Math.max(0, v) || undefined,
                                };
                            })
                        }
                        testId="prop-image-radius"
                    />
                    {/* Borders — mirrors Text + Shape: label + toggle header,
                        card-wrapped color / width / style sub-controls. */}
                    <div className="space-y-2">
                        <div className="flex items-center justify-between">
                            <Label>Borders</Label>
                            <SwitchToggle
                                checked={strokeW > 0}
                                onChange={() =>
                                    onUpdate((draft) => {
                                        if (draft.type !== 'image') return;
                                        if ((draft.style?.stroke?.width ?? 0) > 0) {
                                            draft.style = { ...draft.style, stroke: undefined };
                                        } else {
                                            draft.style = {
                                                ...draft.style,
                                                stroke: {
                                                    ...(draft.style?.stroke ?? {}),
                                                    color: draft.style?.stroke?.color ?? '#0F172A',
                                                    width: 1,
                                                },
                                            };
                                        }
                                    })
                                }
                                testId="prop-image-borders-toggle"
                            />
                        </div>
                        {strokeW > 0 && (
                            <div
                                className="space-y-2 rounded-md border border-border bg-background/40 p-2"
                                data-testid="prop-image-borders-card"
                            >
                                <ColorRow
                                    label="Color"
                                    value={colorAsSolidString(
                                        layer.style?.stroke?.color,
                                        '#0F172A',
                                    )}
                                    onChange={(v) =>
                                        onUpdate((draft) => {
                                            if (draft.type !== 'image') return;
                                            const existing = draft.style?.stroke;
                                            draft.style = {
                                                ...draft.style,
                                                stroke: {
                                                    ...existing,
                                                    color: v,
                                                    width: existing?.width ?? 1,
                                                },
                                            };
                                        })
                                    }
                                    testId="prop-image-stroke-color"
                                />
                                <SliderField
                                    label="Width"
                                    value={strokeW}
                                    min={0}
                                    max={40}
                                    step={0.5}
                                    suffix="px"
                                    onChange={(v) =>
                                        onUpdate((draft) => {
                                            if (draft.type !== 'image') return;
                                            const existing = draft.style?.stroke;
                                            const nextWidth = Math.max(0, v);
                                            if (nextWidth <= 0) {
                                                draft.style = { ...draft.style, stroke: undefined };
                                            } else {
                                                draft.style = {
                                                    ...draft.style,
                                                    stroke: {
                                                        ...existing,
                                                        color: existing?.color ?? '#0F172A',
                                                        width: nextWidth,
                                                    },
                                                };
                                            }
                                        })
                                    }
                                    testId="prop-image-stroke-width"
                                />
                                <div className="space-y-1.5">
                                    <Label>Style</Label>
                                    <Segmented<StrokeDash>
                                        value={strokeDashValue}
                                        options={[
                                            { value: 'solid', label: 'Solid' },
                                            { value: 'dashed', label: 'Dashed' },
                                            { value: 'dotted', label: 'Dotted' },
                                        ]}
                                        onChange={(v) =>
                                            onUpdate((draft) => {
                                                if (draft.type !== 'image') return;
                                                const existing = draft.style?.stroke;
                                                if (!existing) return;
                                                draft.style = {
                                                    ...draft.style,
                                                    stroke: {
                                                        ...existing,
                                                        dash: v === 'solid' ? undefined : v,
                                                    },
                                                };
                                            })
                                        }
                                        testIdPrefix="prop-image-stroke-dash"
                                    />
                                </div>
                            </div>
                        )}
                    </div>
                    {/* Overlay — mirrors Background's image overlay. Renders a
                        color tint on top of the image via source-atop composite
                        so it respects the image's alpha (transparent PNG
                        regions stay clear). */}
                    <div className="space-y-2">
                        <div className="flex items-center justify-between">
                            <Label>Overlay</Label>
                            <SwitchToggle
                                checked={!!layer.overlay}
                                onChange={() =>
                                    onUpdate((draft) => {
                                        if (draft.type !== 'image') return;
                                        if (draft.overlay) {
                                            delete draft.overlay;
                                        } else {
                                            draft.overlay = {
                                                color: '#000000',
                                                opacity: 0.3,
                                            };
                                        }
                                    })
                                }
                                testId="prop-image-overlay-toggle"
                            />
                        </div>
                        {layer.overlay && (
                            <div
                                className="space-y-2 rounded-md border border-border bg-background/40 p-2"
                                data-testid="prop-image-overlay-card"
                            >
                                <ColorRow
                                    label="Color"
                                    value={layer.overlay.color}
                                    onChange={(v) =>
                                        onUpdate((draft) => {
                                            if (draft.type !== 'image' || !draft.overlay) return;
                                            draft.overlay.color = v;
                                        })
                                    }
                                    testId="prop-image-overlay-color"
                                />
                                <SliderField
                                    label="Opacity"
                                    value={Math.round((layer.overlay.opacity ?? 0) * 100)}
                                    min={0}
                                    max={100}
                                    onChange={(v) =>
                                        onUpdate((draft) => {
                                            if (draft.type !== 'image' || !draft.overlay) return;
                                            draft.overlay.opacity = Math.max(0, Math.min(1, v / 100));
                                        })
                                    }
                                    testId="prop-image-overlay-opacity"
                                />
                            </div>
                        )}
                    </div>
                    <ShadowControls
                        layer={layer}
                        onUpdate={onUpdate}
                        testIdPrefix="prop-image-shadow"
                    />
                </>
            )}
        </Section>
    );
}

type CalloutProps = {
    layer: Extract<Layer, { type: 'callout' }>;
    canvas: Canvas;
    onUpdate: LayerPropsProps['onUpdate'];
};

function CalloutControls({ layer, canvas, onUpdate }: CalloutProps) {
    const source = useEditor((s) =>
        s.layout.layers.find((l) => l.id === layer.sourceLayerId),
    );

    const displayRect = resolveAnchors(layer.anchors, canvas);
    let currentZoom = 1;
    let regionWidthPx = 0;
    let regionHeightPx = 0;
    if (source) {
        const sourceRectPx = resolveAnchors(source.anchors, canvas);
        regionWidthPx = Math.max(1, (sourceRectPx.width * layer.sourceRect.width) / 100);
        regionHeightPx = Math.max(1, (sourceRectPx.height * layer.sourceRect.height) / 100);
        if (displayRect.width > 0) {
            currentZoom = displayRect.width / regionWidthPx;
        }
    }
    const setZoom = (nextZoom: number) => {
        if (!source) return;
        const requested = Math.max(0.5, Math.min(4, nextZoom));
        // Cap the requested zoom so the resulting rect fits inside the canvas.
        const maxZoom = Math.min(
            canvas.width / Math.max(1, regionWidthPx),
            canvas.height / Math.max(1, regionHeightPx),
        );
        const clamped = Math.min(requested, maxZoom);
        const newW = regionWidthPx * clamped;
        const newH = regionHeightPx * clamped;
        // Keep the callout's center pinned, then nudge back inside the canvas.
        const cx = displayRect.x + displayRect.width / 2;
        const cy = displayRect.y + displayRect.height / 2;
        let newX = cx - newW / 2;
        let newY = cy - newH / 2;
        newX = Math.max(0, Math.min(canvas.width - newW, newX));
        newY = Math.max(0, Math.min(canvas.height - newH, newY));
        const leftPct = (newX / canvas.width) * 100;
        const topPct = (newY / canvas.height) * 100;
        const widthPct = (newW / canvas.width) * 100;
        const heightPct = (newH / canvas.height) * 100;
        onUpdate((draft) => {
            if (draft.type !== 'callout') return;
            draft.anchors = {
                left: { type: 'percent', value: +leftPct.toFixed(3) },
                top: { type: 'percent', value: +topPct.toFixed(3) },
                width: { type: 'percent', value: +widthPct.toFixed(3) },
                height: { type: 'percent', value: +heightPct.toFixed(3) },
            };
        });
    };

    const cornerRadius = layer.cornerRadius ?? 8;
    const isRoundedRect = layer.shape === 'rounded-rect';

    return (
        <Section title="Callout">
            {isRoundedRect && (
                <SliderField
                    label="Corner radius"
                    value={cornerRadius}
                    min={0}
                    max={200}
                    step={1}
                    suffix="px"
                    onChange={(v) =>
                        onUpdate((draft) => {
                            if (draft.type !== 'callout') return;
                            draft.cornerRadius = Math.max(0, Math.min(200, v));
                        })
                    }
                    testId="prop-callout-corner-radius"
                />
            )}
            {source && (
                <SliderField
                    label="Zoom"
                    value={+currentZoom.toFixed(2)}
                    min={0.5}
                    max={4}
                    step={0.1}
                    suffix="×"
                    onChange={(v) => setZoom(v)}
                    testId="prop-callout-zoom"
                />
            )}
            <CalloutStrokeControls layer={layer} onUpdate={onUpdate} />
            <ShadowControls
                layer={layer}
                onUpdate={onUpdate}
                testIdPrefix="prop-callout-shadow"
            />
        </Section>
    );
}

/**
 * Minimal stroke section for the callout shape's outline. Renders the
 * standard StrokePaintPicker (solid/linear/radial) + a width input. Lives
 * inline because the existing TextStrokeControls / ShapeStrokePaint variants
 * carry layer-type-specific guards we don't want to fold in.
 */
function CalloutStrokeControls({
    layer,
    onUpdate,
}: {
    layer: Extract<Layer, { type: 'callout' }>;
    onUpdate: (recipe: (draft: Layer) => void) => void;
}) {
    const stroke = layer.style?.stroke;
    const enabled = (stroke?.width ?? 0) > 0;

    const colorValue: Color =
        strokeColorAsColor(stroke?.color) ?? { type: 'solid', value: '#0F172A' };

    return (
        <div className="space-y-2">
            <div className="flex items-center justify-between">
                <Label>Borders</Label>
                <SwitchToggle
                    checked={enabled}
                    onChange={() =>
                        onUpdate((draft) => {
                            if (draft.type !== 'callout') return;
                            const existing = draft.style?.stroke;
                            if ((existing?.width ?? 0) > 0) {
                                draft.style = { ...draft.style, stroke: undefined };
                            } else {
                                draft.style = {
                                    ...draft.style,
                                    stroke: {
                                        ...(existing ?? {}),
                                        color: existing?.color ?? '#0F172A',
                                        width: 12,
                                    },
                                };
                            }
                        })
                    }
                    testId="prop-callout-stroke-toggle"
                />
            </div>
            {enabled && (
                <div
                    className="space-y-2 rounded-md border border-border bg-background/40 p-2"
                    data-testid="prop-callout-stroke"
                >
                    <StrokePaintPicker
                        value={colorValue}
                        onChange={(next) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'callout') return;
                                const existing = draft.style?.stroke;
                                draft.style = {
                                    ...draft.style,
                                    stroke: { ...(existing ?? { width: 12 }), color: next },
                                };
                            })
                        }
                        defaultColor="#0F172A"
                        label="Color"
                        testIdPrefix="prop-callout-stroke"
                    />
                    <SliderField
                        label="Width"
                        value={stroke?.width ?? 12}
                        min={0}
                        max={40}
                        step={0.5}
                        suffix="px"
                        onChange={(v) =>
                            onUpdate((draft) => {
                                if (draft.type !== 'callout') return;
                                const existing = draft.style?.stroke;
                                if (v <= 0) {
                                    draft.style = { ...draft.style, stroke: undefined };
                                } else {
                                    draft.style = {
                                        ...draft.style,
                                        stroke: {
                                            ...(existing ?? {}),
                                            color: existing?.color ?? '#0F172A',
                                            width: v,
                                        },
                                    };
                                }
                            })
                        }
                        testId="prop-callout-stroke-width"
                    />
                    <div className="space-y-1.5">
                        <Label>Style</Label>
                        <Segmented<StrokeDash>
                            value={stroke?.dash ?? 'solid'}
                            options={[
                                { value: 'solid', label: 'Solid' },
                                { value: 'dashed', label: 'Dashed' },
                                { value: 'dotted', label: 'Dotted' },
                            ]}
                            onChange={(v) =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'callout') return;
                                    const existing = draft.style?.stroke;
                                    const base = existing ?? { color: '#0F172A', width: 12 };
                                    draft.style = {
                                        ...draft.style,
                                        stroke: {
                                            ...base,
                                            dash: v === 'solid' ? undefined : v,
                                        },
                                    };
                                })
                            }
                            testIdPrefix="prop-callout-stroke-dash"
                        />
                    </div>
                    {stroke?.dash && stroke.dash !== 'solid' && (
                        <SliderField
                            label="Dash gap"
                            value={stroke.dashGap ?? 2}
                            min={0.5}
                            max={8}
                            step={0.25}
                            suffix="×"
                            onChange={(v) =>
                                onUpdate((draft) => {
                                    if (draft.type !== 'callout') return;
                                    const existing = draft.style?.stroke;
                                    if (!existing) return;
                                    draft.style = {
                                        ...draft.style,
                                        stroke: {
                                            ...existing,
                                            dashGap: Math.abs(v - 2) < 0.01 ? undefined : v,
                                        },
                                    };
                                })
                            }
                            testId="prop-callout-stroke-dash-gap"
                        />
                    )}
                </div>
            )}
        </div>
    );
}

/* -------------------------------------------------------------------------- */
/* Primitives                                                                 */
/* -------------------------------------------------------------------------- */

const GEOMETRY_UNIT_KEY = 'appshot.editor.geometryUnit.v1';
type GeometryUnit = 'px' | 'percent';

let geometryUnitValue: GeometryUnit = (() => {
    if (typeof window === 'undefined') return 'px';
    try {
        const raw = window.localStorage.getItem(GEOMETRY_UNIT_KEY);
        return raw === 'percent' ? 'percent' : 'px';
    } catch {
        return 'px';
    }
})();
const geometryUnitListeners = new Set<() => void>();

function subscribeGeometryUnit(cb: () => void) {
    geometryUnitListeners.add(cb);
    return () => geometryUnitListeners.delete(cb);
}
function getGeometryUnit() {
    return geometryUnitValue;
}
function setGeometryUnit(next: GeometryUnit) {
    if (geometryUnitValue === next) return;
    geometryUnitValue = next;
    try {
        window.localStorage.setItem(GEOMETRY_UNIT_KEY, next);
    } catch {
        // non-fatal
    }
    geometryUnitListeners.forEach((cb) => cb());
}
function useGeometryUnit() {
    return useSyncExternalStore(subscribeGeometryUnit, getGeometryUnit, getGeometryUnit);
}

const SECTION_COLLAPSE_KEY = 'appshot.editor.sectionCollapsed.v1';

function readSectionCollapsed(): Record<string, boolean> {
    if (typeof window === 'undefined') return {};
    try {
        const raw = window.localStorage.getItem(SECTION_COLLAPSE_KEY);
        if (!raw) return {};
        const parsed = JSON.parse(raw);
        return parsed && typeof parsed === 'object' ? (parsed as Record<string, boolean>) : {};
    } catch {
        return {};
    }
}

function writeSectionCollapsed(next: Record<string, boolean>) {
    try {
        window.localStorage.setItem(SECTION_COLLAPSE_KEY, JSON.stringify(next));
    } catch {
        // localStorage can fail under quota / private mode — non-fatal
    }
}

const DEFAULT_COLLAPSED_SECTIONS = new Set(['Layer']);

function Section({ title, children }: { title: string; children: React.ReactNode }) {
    const [collapsed, setCollapsed] = useState(() => {
        const stored = readSectionCollapsed()[title];
        if (typeof stored === 'boolean') return stored;
        return DEFAULT_COLLAPSED_SECTIONS.has(title);
    });
    const toggle = () => {
        setCollapsed((prev) => {
            const next = !prev;
            const all = readSectionCollapsed();
            if (next) all[title] = true;
            else delete all[title];
            writeSectionCollapsed(all);
            return next;
        });
    };
    return (
        <div className="space-y-2.5 border-t border-border/60 py-3 first:border-t-0 first:pt-0 last:pb-0">
            <button
                type="button"
                onClick={toggle}
                aria-expanded={!collapsed}
                data-testid={`prop-section-${title.toLowerCase().replace(/\s+/g, '-')}`}
                className="group flex w-full items-center justify-between gap-2 text-left text-[11px] font-medium text-muted-foreground/80 transition-colors hover:text-foreground"
            >
                <span>{title}</span>
                <ChevronDown
                    className={cn(
                        'h-3 w-3 transition-transform',
                        collapsed ? '-rotate-90' : 'rotate-0',
                    )}
                    strokeWidth={2}
                />
            </button>
            {!collapsed && (
                <div className="animate-fade-in space-y-2.5">{children}</div>
            )}
        </div>
    );
}

type NumberFieldProps = {
    label: string;
    value: number;
    onCommit: (v: number) => void;
    min?: number;
    max?: number;
    step?: number;
    suffix?: string;
    testId?: string;
    hideLabel?: boolean;
};

function NumberField({
    label,
    value,
    onCommit,
    min,
    max,
    step,
    suffix,
    testId,
    hideLabel,
}: NumberFieldProps) {
    return (
        <div className="space-y-1">
            {!hideLabel && (
                <Label className="flex items-center justify-between">
                    <span>{label}</span>
                    {suffix && <span className="text-muted-foreground/60">{suffix}</span>}
                </Label>
            )}
            <Input
                type="number"
                value={value}
                min={min}
                max={max}
                step={step ?? 1}
                data-testid={testId}
                onChange={(e) => {
                    const n = Number(e.target.value);
                    if (!Number.isFinite(n)) return;
                    onCommit(n);
                }}
                className="h-8 !rounded-md px-2 py-1 text-xs"
            />
        </div>
    );
}

type SliderFieldProps = {
    label: string;
    value: number;
    min: number;
    max: number;
    step?: number;
    suffix?: string;
    onChange: (v: number) => void;
    testId?: string;
};

function SliderField({
    label,
    value,
    min,
    max,
    step,
    suffix = '%',
    onChange,
    testId,
}: SliderFieldProps) {
    const [draft, setDraft] = useState(String(value));
    useEffect(() => {
        setDraft(String(value));
    }, [value]);
    const commit = () => {
        const n = Number(draft);
        if (!Number.isFinite(n)) {
            setDraft(String(value));
            return;
        }
        const clamped = Math.min(max, Math.max(min, n));
        setDraft(String(clamped));
        if (clamped !== value) onChange(clamped);
    };
    return (
        <div className="space-y-1">
            <Label className="flex items-center justify-between gap-2">
                <span>{label}</span>
                <span className="flex items-center gap-0.5 text-[11px] text-muted-foreground">
                    <input
                        type="number"
                        value={draft}
                        min={min}
                        max={max}
                        step={step ?? 1}
                        onChange={(e) => setDraft(e.target.value)}
                        onBlur={commit}
                        onKeyDown={(e) => {
                            if (e.key === 'Enter') {
                                e.preventDefault();
                                (e.currentTarget as HTMLInputElement).blur();
                            } else if (e.key === 'Escape') {
                                e.preventDefault();
                                setDraft(String(value));
                                (e.currentTarget as HTMLInputElement).blur();
                            }
                        }}
                        data-testid={testId ? `${testId}-input` : undefined}
                        className="w-12 rounded border border-border bg-background px-1 py-0.5 text-right text-[11px] tabular-nums text-foreground outline-none focus:border-accent [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
                    />
                    {suffix && <span>{suffix}</span>}
                </span>
            </Label>
            <input
                type="range"
                value={value}
                min={min}
                max={max}
                step={step ?? 1}
                onChange={(e) => {
                    const n = Number(e.target.value);
                    if (Number.isFinite(n)) onChange(n);
                }}
                className="h-2 w-full cursor-pointer appearance-none rounded-full bg-muted accent-accent"
                data-testid={testId}
            />
        </div>
    );
}

type SegmentedProps<T extends string> = {
    value: T;
    options: Array<{ value: T; label: string }>;
    onChange: (v: T) => void;
    testIdPrefix?: string;
    /** Preserve the label's casing; defaults to the design-system uppercase. */
    preserveCase?: boolean;
};

function Segmented<T extends string>({
    value,
    options,
    onChange,
    testIdPrefix,
    preserveCase,
}: SegmentedProps<T>) {
    return (
        <div className="flex gap-1">
            {options.map((opt) => (
                <button
                    key={opt.value}
                    type="button"
                    onClick={() => onChange(opt.value)}
                    data-testid={testIdPrefix ? `${testIdPrefix}-${opt.value}` : undefined}
                    data-active={value === opt.value ? 'true' : 'false'}
                    className={cn(
                        'flex-1 rounded-md border px-2 py-1 transition-colors',
                        preserveCase
                            ? 'text-xs'
                            : 'text-[11px] font-medium',
                        value === opt.value
                            ? 'border-accent bg-accent/10 text-accent'
                            : 'border-border bg-card text-muted-foreground hover:text-foreground',
                    )}
                >
                    {opt.label}
                </button>
            ))}
        </div>
    );
}

type SwitchToggleProps = {
    checked: boolean;
    onChange: () => void;
    testId?: string;
};

function SwitchToggle({ checked, onChange, testId }: SwitchToggleProps) {
    return (
        <button
            type="button"
            role="switch"
            aria-checked={checked}
            onClick={onChange}
            data-testid={testId}
            data-active={checked ? 'true' : 'false'}
            className={cn(
                // `ring` (not border) for the always-visible pill outline:
                // border-box would shrink the inner box the knob is positioned
                // against (top-0.5/left-0.5), shoving the knob into the corner.
                // A ring is a box-shadow — outlines the track for dark-mode
                // visibility with zero layout shift, so the knob stays centered.
                'relative inline-block h-5 w-9 shrink-0 rounded-full ring-1 transition-colors',
                checked ? 'bg-accent ring-accent' : 'bg-muted ring-border',
            )}
        >
            {/* Knob centered via static `top-0.5 left-0.5` (2px gap on all
                sides of the 16×16 knob inside the 20×16 track) — the X slide
                is purely a `translate-x` transition, never compounded with a
                Y transform. Tailwind 4's transform compositing can drop one
                axis when both `translate-x-*` + `translate-y-*` are present
                in the className list, leaving the knob visually adrift.
                Separating Y placement (static top) from X animation (translate)
                avoids that class of bug entirely. */}
            <span
                aria-hidden
                className={cn(
                    'pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow ring-1 ring-black/10 transition-transform',
                    checked ? 'translate-x-4' : 'translate-x-0',
                )}
            />
        </button>
    );
}

type IconSegmentedProps<T extends string> = {
    value: T;
    options: Array<{
        value: T;
        icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
        label: string;
    }>;
    onChange: (v: T) => void;
    testIdPrefix?: string;
};

function IconSegmented<T extends string>({
    value,
    options,
    onChange,
    testIdPrefix,
}: IconSegmentedProps<T>) {
    return (
        <div className="flex gap-1">
            {options.map((opt) => {
                const Icon = opt.icon;
                const active = value === opt.value;
                return (
                    <button
                        key={opt.value}
                        type="button"
                        onClick={() => onChange(opt.value)}
                        data-testid={
                            testIdPrefix ? `${testIdPrefix}-${opt.value}` : undefined
                        }
                        data-active={active ? 'true' : 'false'}
                        aria-label={opt.label}
                        title={opt.label}
                        className={cn(
                            'flex h-8 flex-1 items-center justify-center rounded-md border transition-colors',
                            active
                                ? 'border-accent bg-accent/10 text-accent'
                                : 'border-border bg-card text-muted-foreground hover:text-foreground',
                        )}
                    >
                        <Icon className="h-3.5 w-3.5" strokeWidth={2} />
                    </button>
                );
            })}
        </div>
    );
}

type ColorRowProps = {
    label: string;
    value: string;
    onChange: (v: string) => void;
    testId?: string;
};

function ColorRow({ label, value, onChange, testId }: ColorRowProps) {
    // Wrapper-label swatch instead of raw `<input type=color>` styled with
    // `bg-card`. Browsers render native color inputs with their own chrome
    // (padding, border, inner swatch margin), which clipped the visible
    // color to a thin sliver under the `rounded` appearance preset on
    // small inputs. The label-as-swatch pattern paints the color cleanly
    // on a sized div and keeps the native picker behavior via a hidden
    // child input.
    return (
        <div className="min-w-0 space-y-1.5">
            <Label>{label}</Label>
            {/* min-w-0 on the flex row + min-w-0 + w-0 on the hex input
                lets `flex-1` actually shrink below the input's intrinsic
                content width (default text inputs render at ~20ch wide).
                Without these, the input pushed past the container's
                rounded-md border + got clipped by the panel's right edge. */}
            <div className="flex min-w-0 items-center gap-2">
                <label
                    className="relative inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border shadow-inner"
                    style={{ backgroundColor: value }}
                    title={value}
                >
                    <input
                        type="color"
                        value={value}
                        onChange={(e) => onChange(e.target.value)}
                        className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
                        data-testid={testId}
                        aria-label={`${label} color picker`}
                    />
                </label>
                <input
                    type="text"
                    value={value}
                    onChange={(e) => onChange(e.target.value)}
                    className="h-9 w-0 min-w-0 flex-1 rounded-md border border-border bg-background px-2 font-mono text-xs text-foreground focus:border-accent focus:outline-none"
                />
            </div>
        </div>
    );
}
