import { useCallback, useEffect, useRef, useState } from 'react';
import Konva from 'konva';

import { Header } from './components/Header';
import { DesignCanvas } from './components/canvas/DesignCanvas';
import { ElementToolbar } from './components/canvas/ElementToolbar';
import { TextEditAffordance } from './components/canvas/TextEditAffordance';
import { DebugOverlay, readDebugFlag } from './components/canvas/DebugOverlay';
import { ZoomControls } from './components/canvas/ZoomControls';
import { HistoryControls } from './components/canvas/HistoryControls';
import { Sidebar } from './components/sidebar/Sidebar';
import { MobileBottomSheet } from './components/MobileBottomSheet';
import { PWAInstall } from './components/PWAInstall';
import { OfflineIndicator } from './components/OfflineIndicator';
import { PhotoPreEditor } from './components/editor/PhotoPreEditor';
import { PreviewModal } from './components/editor/PreviewModal';

import { useDesignEditor } from './hooks/useDesignEditor';
import { useExport } from './hooks/useExport';
import { useTemplate } from './hooks/useTemplate';
import { useFontsReady } from './hooks/useFontsReady';
import { useTimer } from './hooks/useTimer';

import { TEMPLATES } from './constants/templates';
import { SHIRT_COLORS, SHIRT_BASE_PRICE_USD } from './constants/shirt';
import { getActiveProduct } from './constants/products';
import {
  withTextCenterPreservation,
  fontSizeToPreserveWidth,
} from './utils/textGeometry';
import { countOutOfBounds, fitElementsToBounds, fitElementToBounds } from './utils/elementBounds';
import { loadPersistedState, savePersistedState, clearPersistedState } from './utils/persistence';
import { revokeBlobUrl } from './utils/blobUrl';
import { computeCropUpdate } from './utils/cropMath';
import { preloadAllFonts } from './utils/fontPreload';
import { t } from './i18n/t';

import './App.css';

const MOBILE_QUERY = '(max-width: 768px)';
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.1;
// Cap on the recent-colors list shown above the curated palette in the
// Text tab. 6 fits comfortably on a single row at the small swatch size
// without wrapping or scrolling on a typical sidebar width.
const MAX_RECENT_COLORS = 6;
// Cap on recent-fonts. 5 is enough to surface common picks without
// pushing the curated list far enough down to feel buried.
const MAX_RECENT_FONTS = 5;
// How long the Clear button stays "armed" after first click. Two-tap
// confirmation prevents accidental wipes; 3s is short enough to feel
// responsive but long enough to read the toast and tap again.
const CLEAR_CONFIRM_MS = 3000;

// How long the "✅ Saved!" pill lingers after a successful export
// before fading itself out. Long enough that a user finishing a save
// can leave the editor briefly (open Downloads, switch tabs) and
// still see confirmation when they come back; short enough that a
// stale "Saved!" doesn't keep claiming the corner of the canvas
// forever. ~1 minute is the sweet spot from product feedback.
//
// Note this dismisses the VISIBLE PILL only — useExport's exportUrl
// state stays set, so the print-preview modal (which reads exportUrl
// for its image) doesn't lose its content. See the dismissal effect
// further down for the reset-on-new-export behavior.
const EXPORT_TOAST_AUTO_DISMISS_MS = 60000;

// URL query parameter that selects a template (e.g. `?template=team-sport`).
// Templates can ONLY be loaded via this param — the editor itself no longer
// has a templates picker. Marketing/landing pages on the Pawfectly Yours
// site are expected to deep-link users to specific designs.
const TEMPLATE_PARAM = 'template';

// Debug overlay enablement — read ONCE at module load so the conditional
// render gate below stays stable across re-renders. Either `?debug=1` in
// the URL or `localStorage.paw_debug_overlay = '1'` enables it; the URL
// path also persists to localStorage so subsequent reloads keep it on
// without re-appending the param. See DebugOverlay.jsx for the full
// rationale. The overlay is a developer tool for diagnosing the
// rotation-pivot jump bug; in production builds with the flag unset, the
// gate short-circuits and the import is tree-shaken.
const DEBUG_OVERLAY_ENABLED = readDebugFlag();

/**
 * Read the active template id from the current URL, or null if none.
 * Returns null on parse errors (malformed URL, no `window`, etc.) rather
 * than throwing — the caller treats null as "no deep link" and falls back
 * to whatever was previously saved.
 */
function readTemplateFromUrl() {
  if (typeof window === 'undefined') return null;
  try {
    const params = new URLSearchParams(window.location.search);
    const id = params.get(TEMPLATE_PARAM);
    return id && id.trim() ? id.trim() : null;
  } catch {
    return null;
  }
}

function App() {
  /* ─── Editor state ────────────────────────────────────────────────────── */
  const {
    elements, selectedId, selectedIds,
    addElement, replaceElements, updateElement, updateAndCommit, deleteElement, deleteMany,
    duplicateElement, bringForward, sendBackward, reorderElement,
    selectElement, deselectAll, toggleInSelection, setSelection, commitHistory,
    undo, redo, canUndo, canRedo, initializeHistory,
  } = useDesignEditor();

  const { exporting, progress, exportDesign, exportUrl, error, clearExport } = useExport();
  const { currentTemplate, loadTemplate, clearTemplate, assignImageToSlot } = useTemplate(TEMPLATES);

  // Re-render once web fonts have loaded, so the bounds count below uses
  // accurate text-width measurements. Without this, the first computation
  // of `outOfBoundsCount` can use fallback-font widths and either falsely
  // flag (or fail to flag) text elements until the next unrelated update.
  useFontsReady();

  const selectedElement = elements.find((el) => el.id === selectedId) || null;

  /* ─── Bounds enforcement ────────────────────────────────────────────── */
  // Konva's drag/transform bound functions keep elements inside the print
  // area during direct manipulation, but elements can still end up outside
  // through other paths: photo-edit replacing the source with a different
  // aspect ratio, text growing as the user types, programmatic placement
  // from a template, etc. Compute the count once per render and use it
  // for two purposes:
  //   • Render a warning chip inside the canvas frame (DesignCanvas)
  //   • Disable Add-to-Cart in the right panel until the user fixes it
  const { canvasSize: activeCanvasSize } = getActiveProduct();
  const outOfBoundsCount = countOutOfBounds(elements, activeCanvasSize);
  const cartDisabledReason = outOfBoundsCount > 0
    ? t('cart.disabled-out-of-bounds')
    : null;

  /* ─── Pawfectly Yours product state ──────────────────────────────────── */
  const [shirtColorId, setShirtColorId] = useState('white');
  const [shirtSize, setShirtSize] = useState('M');
  const [cartCount, setCartCount] = useState(0);
  const [zoom, setZoom] = useState(1);
  const [recentUploads, setRecentUploads] = useState([]);
  const [editingElement, setEditingElement] = useState(null);
  const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
  // Synchronous matchMedia initializer so the very first paint already
  // matches the device. Without this, mobile devices flashed the desktop
  // layout for ~16ms before the matchMedia effect below caught up.
  const [isMobile, setIsMobile] = useState(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return false;
    return window.matchMedia(MOBILE_QUERY).matches;
  });

  // Recently-used colors, surfaced in the Text tab. Lifted to App so the
  // list survives selection changes, sheet open/close, and tab switches.
  // Capped via `addRecentColor` rather than at render time so the state
  // shape itself is the single source of truth. (Fix for #16.)
  const [recentColors, setRecentColors] = useState([]);
  const addRecentColor = useCallback((color) => {
    if (!color || typeof color !== 'string') return;
    const lower = color.toLowerCase();
    setRecentColors((prev) => {
      const filtered = prev.filter((c) => c.toLowerCase() !== lower);
      return [lower, ...filtered].slice(0, MAX_RECENT_COLORS);
    });
  }, []);

  // Recently-used fonts, surfaced as an optgroup at the top of the Text
  // tab's font dropdown. Same lift-and-cap pattern as recentColors.
  // (Fix for #30.)
  const [recentFonts, setRecentFonts] = useState([]);
  const addRecentFont = useCallback((family) => {
    if (!family || typeof family !== 'string') return;
    setRecentFonts((prev) => {
      const filtered = prev.filter((f) => f !== family);
      return [family, ...filtered].slice(0, MAX_RECENT_FONTS);
    });
  }, []);

  // Shift-key tracking. Used by DesignCanvas → ImageElement/TextElement
  // to enable rotation snap-to-15° while shift is held. Tracked at App
  // level so all selected elements observe the same shift state. We also
  // clear shift on window blur to avoid "stuck shift" when the user
  // tabs away mid-rotation. (Fix for #27.)
  const [shiftHeld, setShiftHeld] = useState(false);
  useEffect(() => {
    const onDown = (e) => { if (e.key === 'Shift') setShiftHeld(true); };
    const onUp   = (e) => { if (e.key === 'Shift') setShiftHeld(false); };
    const onBlur = () => setShiftHeld(false);
    window.addEventListener('keydown', onDown);
    window.addEventListener('keyup', onUp);
    window.addEventListener('blur', onBlur);
    return () => {
      window.removeEventListener('keydown', onDown);
      window.removeEventListener('keyup', onUp);
      window.removeEventListener('blur', onBlur);
    };
  }, []);

  // Print-preview modal open state. Set to true when the user clicks
  // the Preview button in the header; set back to false when the modal
  // is dismissed. The modal reads its loading/error/imageUrl state from
  // useExport — the export pipeline runs in the background while the
  // modal is open, then displays the URL once it's ready. (Fix for #33.)
  const [previewModalOpen, setPreviewModalOpen] = useState(false);

  // Snap-to-center preference. When ON (default), the drag handler
  // pulls an element's center toward the canvas horizontal/vertical
  // centerline and shows a dashed guide; when OFF, the user can
  // position freely with no snap interference. Persisted to
  // localStorage so the user's choice survives reloads. Surfaced as
  // a toggle button in the floating ElementToolbar — see
  // src/components/canvas/ElementToolbar.jsx for the button.
  const [snapEnabled, setSnapEnabled] = useState(true);
  const toggleSnap = useCallback(() => setSnapEnabled((v) => !v), []);

  // Crop mode state declared here, but handlers are defined further
  // down — they depend on showToast (declared with the rest of the
  // toast surface) and elementsRef / stageContainerRef (declared
  // with the other refs). Declaring `cropping` here keeps it grouped
  // with the other per-tool mode flags (snap, etc.); the handlers
  // live below those dependencies. See "Crop mode handlers" block.
  const [cropping, setCropping] = useState(null);

  // Text-editing state (Change 7 + follow-ups).
  //
  // The pencil affordance (desktop) and the mobile "Edit text" button
  // both fire `handleStartTextEdit`. That used to mount an inline
  // textarea overlay on the canvas, but the overlay visibly replaced
  // the styled glyphs with a plain textarea, which read as the text
  // "looking different" during editing. Now the entire edit surface
  // is the right-rail Text tab — the canvas text stays exactly as
  // rendered, and the user changes its content / styling via the
  // populated form. `editingTextElementId` tracks which element is
  // active in the form; selection alone never sets it.
  //
  // `sidebarActiveTab` is lifted here so the same handler can flip
  // the right rail to the Text tab atomically with setting the
  // editing id — Sidebar receives it as a controlled prop.
  const [editingTextElementId, setEditingTextElementId] = useState(null);
  const [sidebarActiveTab, setSidebarActiveTab] = useState('upload');

  // Desktop-only focus signal for the Text tab's "Your message"
  // textarea. The pencil click should put the cursor in the field so
  // the user can immediately start typing; we don't auto-focus on
  // selection (separate concern, deliberately removed earlier). A
  // counter token bumped on each pencil click is the cleanest way to
  // get "fire once per click" semantics across renders — TextTab
  // tracks the last token it saw in a ref and focuses only on the
  // transitions. Mobile doesn't bump this; tapping the mobile
  // "Edit text" button shouldn't summon the soft keyboard
  // unexpectedly (the textarea would scroll into view + push the
  // bottom sheet around).
  const [textEditFocusToken, setTextEditFocusToken] = useState(0);

  const handleStartTextEdit = useCallback(({ id }) => {
    // The mobile-only entry point for text editing. Two surfaces fire
    // this in the current flow:
    //   • the on-canvas pencil affordance (TextEditAffordance, now
    //     gated to isMobile),
    //   • the mobile ElementToolbar's "Edit text" button.
    //
    // Desktop doesn't use this path — desktop auto-switches the right
    // rail to the Text tab whenever a text element becomes selected
    // (see the auto-tab-switch effect below) and the Text tab's edit
    // mode is derived directly from the selectedElement. The pencil
    // affordance isn't rendered on desktop.
    //
    // We still call setSidebarActiveTab here so a programmatic invocation
    // from any path (mobile UI, future desktop UI, tests) lands the user
    // on the Text tab. On mobile that's important because the sheet's
    // tab state is sticky across opens; on desktop it's redundant with
    // the auto-switch effect but harmless.
    selectElement(id);
    setEditingTextElementId(id);
    setSidebarActiveTab('text');
    setIsMobileSheetOpen(true);
  }, [selectElement]);

  // Clear editingTextElementId whenever the user moves away from the
  // element they were editing, or transfer it when the user clicks
  // another text element. MOBILE ONLY — desktop derives
  // editingTextElement directly from selectedElement (see the
  // useMemo / variable below) so there's no state to manage.
  //
  // The behaviour matrix for MOBILE:
  //
  //   selectedId === editingTextElementId
  //     → nothing changed; keep edit mode as-is.
  //
  //   selectedId is a different TEXT element
  //     → transfer edit mode to the new element. The sheet is
  //       already on the Text tab; the form rebinds to the new
  //       element's values on the next render.
  //
  //   selectedId is null OR selectedId is a non-text element
  //     → exit edit mode (user clicked off, or clicked on something
  //       else). Snap the sheet's tab back to Upload Photo so the
  //       next sheet-open doesn't show a stale text form.
  //
  // Adding a new element exits edit mode via the addElement wrappers
  // below — each clears editingTextElementId BEFORE addElement runs,
  // so the new element's selection doesn't enter the transfer branch.
  useEffect(() => {
    if (!isMobile) return;
    if (!editingTextElementId) return;
    if (selectedId === editingTextElementId) return;

    const newSelected = selectedId
      ? elementsRef.current.find((e) => e.id === selectedId)
      : null;

    if (newSelected && newSelected.type === 'text') {
      // Transfer.
      setEditingTextElementId(selectedId);
      return;
    }

    // Exit.
    setEditingTextElementId(null);
    setSidebarActiveTab((prev) => (prev === 'text' ? 'upload' : prev));
  }, [selectedId, editingTextElementId, isMobile]);

  // Desktop auto-tab-switch on text-selection transitions.
  //
  // The user explicitly asked for the older flow: selecting a text
  // element on the canvas should switch the right rail to the Text
  // tab; deselecting (or selecting a non-text element) should switch
  // back to Upload Photo. No explicit edit gesture on desktop.
  //
  // The naive approach ("whenever selectedElement isn't a text
  // element, snap to Upload") would yank users out of any tab they
  // manually navigated to while no text was selected — imagine the
  // user is browsing the Stickers tab, clicks an emoji which becomes
  // selected, and we snap them back to Upload. They didn't deselect
  // text; they never had text selected.
  //
  // So we track the PREVIOUS selection type and only fire the
  // tab-snap on transitions involving text:
  //
  //   text → anything    : snap to Upload (only if currently on Text;
  //                         leave alone if user already moved away).
  //   anything → text     : snap to Text (always).
  //   not-text → not-text : do nothing (user is doing something
  //                         unrelated to text editing).
  //
  // Implementation note (bug fix):
  // ─────────────────────────────────────────────────────────────
  // Previous version read the selected element's type from
  // `elementsRef.current.find(...)`, but `elementsRef` is declared
  // and synced via a separate useEffect that lives ~200 lines BELOW
  // this one in the component body. React fires useEffects in
  // declaration order, so on the render where `selectedId` jumps to
  // a freshly-added text element, this effect ran BEFORE the
  // elementsRef sync — the ref still held the previous render's
  // elements (no new text element yet), `find` returned undefined,
  // and `currentType` came out as null. That null then got stored in
  // `prevSelectedTypeRef`, so the SUBSEQUENT deselect (text → null)
  // read prevType as null instead of 'text' and never fired the
  // "snap back to Upload" branch. Net symptom: deselecting text
  // right after adding-and-editing left the user stuck on the Text
  // tab.
  //
  // Fix: use `selectedElement?.type` directly. `selectedElement` is
  // computed at the top of every render from the CURRENT `elements`
  // array, so it's always consistent with what the user just saw on
  // screen — no race with effect ordering. Depend on the resolved
  // type (a string) rather than on `selectedId`, so the effect only
  // fires on actual type transitions, not on every per-character
  // text-content update.
  const currentSelectedType = selectedElement?.type ?? null;
  const prevSelectedTypeRef = useRef(null);
  useEffect(() => {
    if (isMobile) return;
    const prevType = prevSelectedTypeRef.current;
    prevSelectedTypeRef.current = currentSelectedType;

    if (currentSelectedType === 'text') {
      // Always switch into Text tab when text becomes selected.
      setSidebarActiveTab('text');
      return;
    }
    if (prevType === 'text') {
      // Just transitioned away from text (deselect, or selected a
      // non-text element). Per user spec, snap back to Upload. The
      // conditional setter preserves a tab the user manually moved
      // to mid-text-selection — we only override if we're still on
      // Text from the original auto-switch.
      setSidebarActiveTab((prev) => (prev === 'text' ? 'upload' : prev));
    }
    // Otherwise: no transition involving text, leave the tab alone.
  }, [currentSelectedType, isMobile]);

  // Resolved "element currently being edited" — derived differently
  // per platform:
  //
  //   • Desktop: the SELECTED text element IS the one being edited.
  //     There's no separate gesture to enter edit mode; the Text tab
  //     auto-switches in on selection and edits flow live to the
  //     selected element. selectedElement is already kept in sync
  //     with elements via useDesignEditor's updates.
  //
  //   • Mobile: edit mode is gated behind an explicit pencil tap
  //     (handleStartTextEdit sets editingTextElementId). The resolve
  //     here looks up the id in elements so any live updates to the
  //     element's text / font / etc. flow through to the form.
  //
  // Returns null when no edit is active OR when the edited element
  // has been deleted (defensive: a stale id won't crash the side
  // panel).
  const editingTextElement = isMobile
    ? (editingTextElementId
        ? elements.find((el) => el.id === editingTextElementId && el.type === 'text') || null
        : null)
    : (selectedElement?.type === 'text' ? selectedElement : null);

  // Clear-canvas two-tap confirmation. First click arms the action and
  // shows a toast; second click within CLEAR_CONFIRM_MS performs the
  // clear. The timer auto-cancels on unmount via useTimer's cleanup.
  // (Fix for #35.)
  const [clearArmed, setClearArmed] = useState(false);
  const clearArmTimer = useTimer();

  // Canvas hint state. The hint shows on first load to teach "click and
  // drag to move" and dismisses on the user's first concrete interaction
  // (adding any element). Once dismissed it stays dismissed for the
  // session — the user has clearly figured it out and reshowing the hint
  // every time they deselect or delete back to empty is patronizing.
  // (Fix for #15.)
  const [hintDismissed, setHintDismissed] = useState(false);
  useEffect(() => {
    if (elements.length > 0 && !hintDismissed) setHintDismissed(true);
  }, [elements.length, hintDismissed]);

  // App-wide toast surface. Replaces ad-hoc `alert()` calls in handleSave,
  // handleShare, handleOpenCart, and handleSlotImageUpload with a
  // consistent, dismissable, auto-expiring notification. Auto-dismiss
  // duration is per-call so the share-fallback URL can linger long enough
  // to read while a simple "link copied" toast goes away quickly.
  // (Fix for #19.)
  const [toast, setToast] = useState(null);
  const toastTimer = useTimer();
  const showToast = useCallback((message, kind = 'info', duration = 3000) => {
    setToast({ message, kind, id: Date.now() });
    if (duration > 0) {
      toastTimer.schedule(() => setToast(null), duration);
    } else {
      toastTimer.cancel();
    }
  }, [toastTimer]);
  const dismissToast = useCallback(() => {
    toastTimer.cancel();
    setToast(null);
  }, [toastTimer]);

  // Export-toast auto-dismiss (Change 22).
  //
  // The "✅ Saved!" pill that sits below the canvas after a successful
  // export used to hang around indefinitely — the only way to get rid
  // of it was the small ✕ button or clicking Download. After a minute
  // it just reads as visual clutter pinned to the canvas. This state
  // hides the pill independently of useExport's exportUrl, so the
  // print-preview modal (which reads exportUrl as `imageUrl`) keeps
  // its image even after the pill is gone.
  //
  // Why a separate state instead of calling clearExport() after the
  // timeout: clearExport sets exportUrl = null, which would wipe the
  // preview modal's imageUrl mid-view. We want the PILL hidden, not
  // the underlying export state cleared.
  const [exportToastDismissed, setExportToastDismissed] = useState(false);
  const exportToastTimer = useTimer();

  // Reset the dismissed flag whenever an export STARTS or fails so a
  // fresh attempt re-shows the pill. The progress pill while exporting
  // is always visible (it has its own "this is the active operation"
  // semantics); only the success pill is auto-dismissed.
  useEffect(() => {
    if (exporting || error) {
      setExportToastDismissed(false);
      exportToastTimer.cancel();
    }
  }, [exporting, error, exportToastTimer]);

  // Schedule the auto-dismiss when exportUrl becomes available and the
  // pill hasn't already been dismissed. Re-running on dep changes is
  // safe — useTimer.schedule cancels any prior pending callback before
  // setting up the new one, so we don't end up with two parallel
  // timers if React re-runs this effect mid-cycle. When exportUrl
  // becomes null (user clicked ✕ or a new export started), we clear
  // the pending timer to avoid a phantom dismiss firing later.
  useEffect(() => {
    if (!exportUrl || exporting || error || exportToastDismissed) {
      exportToastTimer.cancel();
      return;
    }
    exportToastTimer.schedule(
      () => setExportToastDismissed(true),
      EXPORT_TOAST_AUTO_DISMISS_MS,
    );
  }, [exportUrl, exporting, error, exportToastDismissed, exportToastTimer]);

  const canvasRef = useRef(null);
  // (Crop handlers are defined further down, after elementsRef +
  // stageContainerRef so their bodies can read the latest elements
  // array and Konva stage container without TDZ.)
  const toolbarRef = useRef(null);
  // Refs we exclude from the click-outside-to-deselect handler. The zoom
  // controls live inside canvas-area but interacting with them shouldn't
  // wipe the user's selection. The stage container ref is used to detect
  // "the click landed on Konva's <canvas>" precisely — better than the
  // brittle `target.tagName === 'CANVAS'` check, which would also match
  // any other canvas element on the page.
  const zoomRef = useRef(null);
  const stageContainerRef = useRef(null);

  // Mirror `elements` into a ref so the keyboard nudge handler can read
  // the latest array without re-attaching its listener on every element
  // change. Without this, every drag/transform/text edit would tear down
  // and re-add a global keydown listener — cheap individually but
  // unnecessary churn during continuous interactions.
  const elementsRef = useRef(elements);
  useEffect(() => { elementsRef.current = elements; }, [elements]);

  // Internal clipboard for the Cmd/Ctrl+C / Cmd/Ctrl+V shortcuts. Holds
  // an array of element ids that were selected when the user pressed
  // Cmd+C. On Cmd+V, each id is passed through `duplicateElement`,
  // which produces the same +12/+12 offset and "insert immediately
  // above source in z-order" behavior as the duplicate button and
  // Cmd+D — just with the ability to paste repeatedly and to copy
  // now / paste later. A ref (not state) because nothing in the UI
  // needs to re-render when the clipboard changes; it's purely
  // consumed at paste time.
  //
  // If the user deletes a copied element before pasting,
  // duplicateElement's existence check makes that id a no-op and the
  // paste silently produces nothing for that id — matching the
  // duplicate-by-id contract. We deliberately don't snapshot element
  // *data* into the clipboard, since "paste" is semantically
  // "duplicate-of-current-state" here, not a static snapshot.
  const clipboardRef = useRef([]);

  const shirtColorHex = SHIRT_COLORS.find((c) => c.id === shirtColorId)?.hex || '#ffffff';

  // ----------------------------------------------------------------
  // Crop mode handlers (image elements only).
  //
  // Sited here, after showToast and the refs, because:
  //   • handleStartCrop's useCallback dep array references showToast.
  //     Putting this block above showToast's declaration triggers a
  //     TDZ error ("Cannot access 'showToast' before initialization")
  //     since the dep array is evaluated at render time.
  //   • handleApplyCrop reads elementsRef.current and
  //     stageContainerRef.current to resolve the underlying
  //     HTMLImageElement's natural dimensions. Refs are stable across
  //     renders, so reading them at call time is fine — but keeping
  //     this block below their declarations preserves top-to-bottom
  //     readability.
  //
  // State (`cropping`) is declared with the other per-tool flags
  // above (alongside snapEnabled). Shape:
  //   {
  //     elementId: string,
  //     rect: { x, y, width, height }  // canvas/design coords
  //   }
  //
  // DesignCanvas mounts <CropOverlay> when cropping is non-null;
  // ElementToolbar replaces its normal face with Apply / Cancel
  // buttons; the targeted element renders with selection chrome
  // suppressed so the crop UI has visual focus.
  // ----------------------------------------------------------------
  const handleStartCrop = useCallback((element) => {
    if (!element || (element.type !== 'image' && element.type !== 'sticker')) return;
    // Rotation gate: the crop overlay (dim mask, crop rect, transformer
    // handles) is axis-aligned. If the image is rotated, the overlay
    // wouldn't visually align with the displayed image — the user
    // would see a crop UI floating off to the side of the actual
    // photo. Easiest correct answer: refuse, and tell the user to
    // reset rotation first. Rotating-then-cropping is rare; users
    // typically crop first, then rotate the result.
    if ((element.rotation ?? 0) !== 0) {
      showToast(t('toast.crop-rotation-blocked'), 'info', 4000);
      return;
    }
    setCropping({
      elementId: element.id,
      // Start with the crop rect covering the ENTIRE element — same
      // x/y/width/height as the image itself. The handles render on
      // top of the rect's stroke at each corner and edge midpoint,
      // so they're visible at the image's edges even at zero inset.
      // The user shrinks inward from this full-bleed starting box;
      // there's no expand-outward path because the boundBoxFunc in
      // CropOverlay clamps the rect to the element's bounds.
      rect: {
        x: element.x,
        y: element.y,
        width: element.width,
        height: element.height,
      },
    });
  }, [showToast]);

  const handleCropRectChange = useCallback((newRect) => {
    setCropping((curr) => (curr ? { ...curr, rect: newRect } : curr));
  }, []);

  const handleCancelCrop = useCallback(() => {
    setCropping(null);
  }, []);

  // Apply crop: convert the canvas-coord cropRect into source-image
  // coords and update the element's `crop`, `x`, `y`, `width`,
  // `height` atomically. The new element bbox matches the crop rect
  // (the cropped image takes the rect's size and position); the
  // `crop` attribute carries the source-image sub-region that
  // Konva.Image should render.
  //
  // The actual math (flip correction, multi-crop composition,
  // tracking-field semantics) lives in `src/utils/cropMath.js` as a
  // pure function with its own tests. Here we just resolve the
  // natural dimensions from the Konva stage, hand the resolved
  // inputs to the pure function, and commit the resulting patch.
  // This keeps handleApplyCrop focused on the React/Konva
  // integration concerns (state, side effects, error toasts) and
  // moves the testable math to a place where it's reachable without
  // mounting the editor.
  //
  // For the natural dimensions we walk the Konva stage to find the
  // image node by name and read node.image()?.naturalWidth/Height.
  // This is the same Konva.stages lookup pattern used elsewhere.
  const handleApplyCrop = useCallback(() => {
    // Resolution-then-commit, NOT setCropping((curr) => ...) with a
    // side effect inside.
    //
    // The previous shape — setCropping(updater) with updateAndCommit
    // called from inside the updater — was a React 18 anti-pattern
    // (side effects in state updaters), and the bigger problem was
    // its silent-failure mode: if naturalW/H couldn't be resolved
    // (e.g. the image hadn't finished loading at the moment the user
    // clicked Apply), the updater returned `null` to exit crop mode
    // but never called updateAndCommit. No history snapshot for the
    // crop was saved. The user saw crop mode disappear and assumed
    // the crop landed; pressing Cmd+Z then walked back to the entry
    // BEFORE the most recent action, which was often the empty
    // canvas ("add image" → "crop image" → silent-fail crop → Undo =
    // remove the image). That's the "canvas ends up blank" bug.
    //
    // New shape:
    //   1. Read the current `cropping` state directly (not via
    //      setCropping updater).
    //   2. Try to resolve natural dims. If we can't, KEEP crop mode
    //      active and surface a toast — the user can wait a moment
    //      and try Apply again. This guarantees: either we commit AND
    //      exit crop mode, or we do nothing and remain in crop mode.
    //      No silent state change.
    //   3. If we can, call updateAndCommit, then setCropping(null)
    //      OUTSIDE any updater so the state mutations are clean,
    //      side-effect-free updates.
    //
    // This also makes the strict-mode double-invocation harmless:
    // there's no updater function with side effects, so React's
    // dev-mode impurity check has nothing to double-execute.
    if (!cropping) return;
    const element = elementsRef.current.find((e) => e.id === cropping.elementId);
    if (!element) {
      // The cropped element vanished out from under us — nothing to
      // commit. Exit crop mode silently.
      // eslint-disable-next-line no-console
      if (typeof localStorage !== 'undefined' && localStorage.getItem('paw_debug_history') === '1') {
        console.log('[history] handleApplyCrop BAIL', { reason: 'element vanished', elementId: cropping.elementId });
      }
      setCropping(null);
      return;
    }

    // Resolve natural dimensions from the Konva image node.
    let naturalW = 0;
    let naturalH = 0;
    const container = stageContainerRef.current;
    if (container && Konva && Array.isArray(Konva.stages)) {
      for (const s of Konva.stages) {
        try {
          if (s.container() === container) {
            const node = s.findOne('.' + element.id);
            const img = node?.image?.();
            if (img) {
              naturalW = img.naturalWidth || img.width;
              naturalH = img.naturalHeight || img.height;
            }
            break;
          }
        } catch { /* ignore */ }
      }
    }
    if (!naturalW || !naturalH) {
      // Image isn't loaded yet (or stage lookup failed). Keep crop
      // mode active so the user can retry once the image has finished
      // loading; surface a toast so they understand why nothing
      // happened.
      //
      // Critically, we do NOT setCropping(null) here — the previous
      // version did, which silently dropped the user's crop work AND
      // poisoned the undo history (next Cmd+Z would walk past the
      // "add image" entry instead of undoing the just-apparent
      // crop). Staying in crop mode is the right behavior for the
      // retry; the user can also press Escape to cancel out of crop
      // mode explicitly if they don't want to wait.
      // eslint-disable-next-line no-console
      if (typeof localStorage !== 'undefined' && localStorage.getItem('paw_debug_history') === '1') {
        console.log('[history] handleApplyCrop BAIL', {
          reason: 'natural dimensions not available (image still loading?)',
          elementId: element.id,
          note: 'crop mode kept active for retry; no setCropping(null), no updateAndCommit',
        });
      }
      showToast(t('toast.crop-not-ready'), 'info', 3000);
      return;
    }

    // Compute the update patch via the pure function in cropMath.js.
    // computeCropUpdate handles flip correction, multi-crop
    // composition, and the preCrop/postCrop/previousCrop tracking
    // fields. See src/utils/cropMath.js for the full math; tests
    // covering the bug-prone shapes (flip, multi-crop drift, edge
    // cases) live in src/utils/cropMath.test.js.
    //
    // The function returns null on degenerate inputs (zero dims,
    // missing naturals). We've already guarded the most likely null
    // causes above (element vanished, image still loading), so
    // reaching the null branch here means the cropRect itself is
    // malformed somehow. Bail rather than commit a partial patch.
    // The crop overlay's boundBoxFunc should prevent this in normal
    // use; treat the null check as defensive insurance.
    const patch = computeCropUpdate({
      element,
      cropRect: cropping.rect,
      naturalW,
      naturalH,
    });
    if (!patch) return;
    updateAndCommit(element.id, patch);
    setCropping(null);
  }, [cropping, updateAndCommit, showToast]);

  // Undo crop: roll back the most recent crop operation. Two branches
  // depending on whether the user has resized the element since the
  // last crop apply:
  //
  //   Element NOT resized since crop apply
  //     → Restore the pre-crop bounding box AND the prior crop state.
  //       `element.preCrop` holds the dimensions captured at the last
  //       crop apply, and `element.previousCrop` holds the crop value
  //       that was active immediately before that apply (null if it
  //       was the first crop on the element). Restoring both means
  //       the user lands back at "the state immediately before the
  //       last crop click" — for a multi-crop sequence, that's the
  //       PREVIOUS crop, not a fully uncropped image. Matches the
  //       button label's promise: undo only the last crop operation.
  //
  //   Element resized since crop apply
  //     → The user moved on from the crop — the resize is a
  //       deliberate "this is the size I want" gesture, and the crop
  //       is now stale relative to the new envelope. Keep the
  //       resized bounds, fit the FULL (uncropped) natural aspect
  //       inside them, and clear the crop. This is intentionally NOT
  //       a literal undo of the crop — unwinding to a previous crop
  //       state with the current resized bounds would mix two
  //       intentions (the resize stays, the crop reverts) in a way
  //       that's hard to predict. Cleaner to treat the resize as a
  //       checkpoint: undo across it acts like a fit-to-bounds
  //       reset rather than a literal crop-history rewind.
  //
  // Resize-detection uses `element.postCrop`, the dimensions we
  // recorded immediately after the last crop apply. If they still
  // match the current width/height (within a 0.5px tolerance for
  // rounding), no resize has happened. Tolerance rather than strict
  // equality because `handleApplyCrop` rounds to integers via
  // `Math.round` but downstream transforms may apply sub-pixel
  // fractional updates before undo is called.
  //
  // Legacy elements (cropped before this tracking existed) have no
  // preCrop / postCrop / previousCrop. We treat them as the
  // "resized" branch — safer to fit-to-current-bounds than to
  // invent history we can't actually know.
  //
  // Single-step only. After undo, the *Crop tracking fields are
  // cleared, so a subsequent undo click (if the restored state
  // still has a crop) hits the legacy / fit-to-bounds branch and
  // behaves as a full uncrop rather than continuing to walk back a
  // history we don't keep. For multi-step undo across cropping AND
  // other operations, Cmd+Z is the right tool — this button is
  // specifically "undo the crop, even if I've done other things
  // since", which Cmd+Z can't target.
  //
  // Sticker is included alongside image for symmetry, but sticker
  // elements aren't currently croppable through the UI; the type
  // gate is defensive only.
  const handleUndoCrop = useCallback((element) => {
    if (!element || (element.type !== 'image' && element.type !== 'sticker')) return;
    if (!element.crop) return;

    let naturalW = 0;
    let naturalH = 0;
    const container = stageContainerRef.current;
    if (container && Konva && Array.isArray(Konva.stages)) {
      for (const s of Konva.stages) {
        try {
          if (s.container() === container) {
            const node = s.findOne('.' + element.id);
            const img = node?.image?.();
            if (img) {
              naturalW = img.naturalWidth || img.width;
              naturalH = img.naturalHeight || img.height;
            }
            break;
          }
        } catch { /* ignore */ }
      }
    }
    if (!naturalW || !naturalH) return;

    // Resize-since-crop detection. Missing postCrop = legacy element,
    // treat as resized so we go through the safe fit-to-bounds path.
    const post = element.postCrop;
    const wasResized = !post
      || Math.abs(element.width - post.width) > 0.5
      || Math.abs(element.height - post.height) > 0.5;

    let newWidth;
    let newHeight;
    let newCrop;

    if (!wasResized && element.preCrop) {
      // Not resized since the crop: undo it literally. Restore both
      // the pre-crop bounding box AND the prior crop value, so a
      // multi-crop sequence (crop A, then crop B) goes back to the
      // state after A rather than to fully-uncropped. `previousCrop`
      // is null when the most recent crop was the FIRST one on the
      // element; in that case `newCrop` falls through to undefined
      // and the image renders fully uncropped at the restored
      // dimensions, which is correct.
      newWidth = element.preCrop.width;
      newHeight = element.preCrop.height;
      newCrop = element.previousCrop || undefined;
    } else {
      // Resized since the crop OR legacy element with no preCrop
      // tracking. Fit the natural aspect ratio inside the current
      // bounds (letterboxing along whichever axis is needed) and
      // clear the crop entirely — we don't try to literally undo
      // the crop in this branch because mixing "crop undo" with the
      // user's intervening resize gives unpredictable results.
      //
      //   naturalAspect > boundsAspect : image is wider than the
      //     bounding box at current scale → fill the box's WIDTH,
      //     let the height shrink to match natural aspect.
      //
      //   naturalAspect ≤ boundsAspect : image is taller (or square)
      //     relative to bounds → fill HEIGHT, let width shrink.
      const naturalAspect = naturalW / naturalH;
      const boundsAspect = element.width / element.height;
      if (naturalAspect > boundsAspect) {
        newWidth = element.width;
        newHeight = element.width / naturalAspect;
      } else {
        newHeight = element.height;
        newWidth = element.height * naturalAspect;
      }
      newCrop = undefined;
    }

    // Preserve current visual center so undo doesn't make the
    // element appear to jump to a different position. Element x/y
    // are top-left, so we compute center from current dims and offset
    // the new dims around the same center point.
    const currentCenterX = element.x + element.width / 2;
    const currentCenterY = element.y + element.height / 2;
    const newX = currentCenterX - newWidth / 2;
    const newY = currentCenterY - newHeight / 2;

    updateAndCommit(element.id, {
      x: Math.round(newX),
      y: Math.round(newY),
      width: Math.round(newWidth),
      height: Math.round(newHeight),
      crop: newCrop,
      // Clear all the tracking fields. Single-step undo: we don't
      // keep a history stack, so any follow-up undo click hits the
      // legacy / fit-to-bounds branch. A subsequent crop will
      // repopulate these fields fresh.
      preCrop: undefined,
      postCrop: undefined,
      previousCrop: undefined,
    });
  }, [updateAndCommit]);

  // Selection change while in crop mode → APPLY the crop and exit
  // crop mode. Two gestures land here:
  //
  //   1. User clicks empty canvas (Stage onClick → onDeselect →
  //      selectedId = null).
  //   2. User clicks a different element (handleElementSelect →
  //      onSelect → selectedId = other-id).
  //
  // Both are read as "I'm done with this crop" by the user, so we
  // commit their work via handleApplyCrop rather than dropping it.
  // The previous behavior (silently exiting crop mode with no apply)
  // was a foot-gun — a user could spend time dragging anchors and
  // then lose all of it by clicking off without noticing.
  //
  // Escape is the only path to CANCEL a crop without applying.
  // See the Escape branch in the keyboard handler below; it gates
  // before this effect by setting cropping = null directly, so this
  // effect's `if (cropping && ...)` check bails before handleApplyCrop
  // can fire.
  //
  // Konva anchor drags don't fire click events (the drag suppresses
  // them), so anchor interactions never trigger this effect via
  // bubbled Stage clicks. Only "true" deselect gestures do.
  useEffect(() => {
    if (cropping && selectedId !== cropping.elementId) {
      handleApplyCrop();
    }
  }, [selectedId, cropping, handleApplyCrop]);

  // Pre-load every design-system font at mount so canvas-based text
  // measurements (used by fontSizeToPreserveWidth and the bounds chip)
  // get real per-font metrics instead of fallback-font metrics. With
  // Google Fonts' `font-display: swap`, each woff2 file is otherwise
  // lazy-fetched on first use — meaning the first time a user picks,
  // say, Bebas Neue from the dropdown, `measureTextWidth` is asked
  // about a font the browser hasn't downloaded yet and silently
  // returns the fallback (system sans) width. The width-preservation
  // math then runs against the wrong number and the computed
  // fontSize is wrong: the text renders narrower than intended.
  //
  // See src/utils/fontPreload.js for the full rationale and cost
  // notes. Fire-and-forget here; document.fonts.ready (which
  // useFontsReady above watches) waits for the in-flight loads to
  // settle, so any ready-gated UI updates retry once measurements
  // become accurate.
  useEffect(() => {
    preloadAllFonts();
  }, []);

  /* ─── Mobile breakpoint detection ─────────────────────────────────────── */
  // Using matchMedia rather than window.innerWidth + resize listener because
  // matchMedia fires once per breakpoint crossing, not on every resize tick.
  useEffect(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return;
    const mql = window.matchMedia(MOBILE_QUERY);
    const update = () => setIsMobile(mql.matches);
    update();
    if (mql.addEventListener) {
      mql.addEventListener('change', update);
      return () => mql.removeEventListener('change', update);
    }
    // Safari < 14 fallback
    mql.addListener(update);
    return () => mql.removeListener(update);
  }, []);

  /* ─── Initialize undo history (after restoration) ────────────────────── */
  //
  // initializeHistory is called from the persistence effect below, after
  // any restoration work has run. The function is safe to call in any
  // order: it seeds an empty floor when history is empty (fresh session)
  // and is a no-op when history already has entries (restoration just
  // pushed something). See the docblock on initializeHistory in
  // useDesignEditor.js for the full contract.
  //
  // Pre-May 23 2026 this site held a `restorationComplete` boolean state
  // and a separate effect that fired initializeHistory when the flag
  // flipped to true. The indirection existed because initializeHistory
  // used to UNCONDITIONALLY wipe history to [empty], so calling it
  // before replaceElements had pushed the restored snapshot would
  // destroy that snapshot and produce a blank-canvas-on-undo bug. May
  // 22's fix made initializeHistory safe regardless of call order, so
  // we can just call it directly at the end of the persistence effect
  // and skip the flag entirely.

  /* ─── Keyboard shortcuts ──────────────────────────────────────────────── */
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

      if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
        e.preventDefault();
        if (canUndo) undo();
        return;
      }
      if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
        e.preventDefault();
        if (canRedo) redo();
        return;
      }
      // Cmd/Ctrl+D → duplicate selection (S3). Operates on the current
      // single selection. With multi-select active, we duplicate each
      // id; the new ids replace the selection so the user can immediately
      // act on them.
      if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
        e.preventDefault();
        if (selectedIds && selectedIds.size > 0) {
          for (const id of selectedIds) {
            duplicateElement(id);
          }
        }
        return;
      }

      // Cmd/Ctrl+C → copy the current selection's ids into the internal
      // clipboard. Silent (no toast / visible feedback) to match the
      // OS-level copy convention — the user trusts it worked and
      // confirms by pasting.
      //
      // Two gates make this play nicely with the rest of the page:
      //   1. The early input/textarea return at the top of this handler
      //      means typing in any form field gets native browser copy
      //      (selecting field text and Cmd+C copies that text instead).
      //   2. If the user has highlighted text anywhere else on the page
      //      (an instructional label, a price string, etc.), we bail
      //      out and let the browser copy that text — hijacking Cmd+C
      //      in that case would be surprising. window.getSelection()
      //      returns a Selection whose toString() is empty when nothing
      //      is highlighted, so the length check below is a reliable
      //      "is there a text selection?" test.
      if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey && !e.altKey) {
        const textSelection = typeof window !== 'undefined' && window.getSelection
          ? window.getSelection().toString()
          : '';
        if (textSelection.length > 0) return;
        if (selectedIds && selectedIds.size > 0) {
          e.preventDefault();
          clipboardRef.current = [...selectedIds];
        }
        return;
      }

      // Cmd/Ctrl+V → paste from the internal clipboard. Each id goes
      // through duplicateElement, which gives us:
      //   • +12/+12 offset on x/y (bottom-right of the source’s position)
      //   • Insertion immediately above the source in the elements array,
      //     which is also "above in the layers panel" (Konva paints
      //     later-in-array on top, and the LayersPanel renders the array
      //     top-down with the topmost array entry as the highest layer)
      //   • Selection moves to the new element
      //
      // Same as Cmd+D in terms of result, but Cmd+V can be repeated
      // without re-selecting and can re-paste an element that was
      // copied earlier in the session even if the user has selected
      // something else in between.
      //
      // For multi-element paste, duplicateElement sets selection to
      // just the last new id (its useCallback owns selection). We
      // collect every returned newId and call setSelection at the
      // end to expand selection to the full pasted set, so the user
      // can drag or transform the whole group at once.
      if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey && !e.altKey) {
        if (clipboardRef.current.length === 0) return;
        e.preventDefault();
        const newIds = [];
        for (const id of clipboardRef.current) {
          const newId = duplicateElement(id);
          if (newId) newIds.push(newId);
        }
        if (newIds.length > 1) setSelection(newIds);
        return;
      }
      if ((e.key === 'Delete' || e.key === 'Backspace') && (selectedId || (selectedIds && selectedIds.size > 0))) {
        if (selectedIds && selectedIds.size > 1) {
          deleteMany([...selectedIds]);
        } else if (selectedId) {
          deleteElement(selectedId);
        }
        return;
      }

      // Enter — in crop mode, APPLY the crop. Mirrors the Apply
      // button on the toolbar. The early input/textarea return at the
      // top of this handler keeps Enter inside form fields native
      // (newline in a textarea, submit in an input), so this branch
      // only ever fires when crop mode owns the foreground.
      if (e.key === 'Enter' && cropping) {
        e.preventDefault();
        handleApplyCrop();
        return;
      }

      // Esc — in crop mode, CANCEL the crop without applying.
      // Outside crop mode, deselect whatever's selected.
      //
      // Doing this here rather than delegating to the canvas's
      // click-empty-space deselect means the user doesn't have to
      // know where the canvas's empty space is. The crop branch
      // runs FIRST so that pressing Escape while cropping doesn't
      // also trigger deselectAll — which would hit the selection-
      // watch effect above and apply the crop (the opposite of what
      // the user wanted by pressing Escape).
      if (e.key === 'Escape') {
        if (cropping) {
          e.preventDefault();
          setCropping(null);
          return;
        }
        if (selectedId || (selectedIds && selectedIds.size > 0)) {
          e.preventDefault();
          deselectAll();
        }
        return;
      }

      // [ / ] for z-order (S6). The toolbar buttons that wrap these
      // already exist (`bringForward` / `sendBackward`); the keys just
      // bind them to the same Photoshop / Figma muscle memory. `]`
      // moves forward (toward the viewer), `[` moves backward.
      if (selectedId && (e.key === ']' || e.key === '[') && !e.ctrlKey && !e.metaKey) {
        e.preventDefault();
        if (e.key === ']') bringForward(selectedId);
        else sendBackward(selectedId);
        commitHistory();
        return;
      }

      // Cmd/Ctrl+A — select every element on the canvas. Pairs with
      // the canvas marquee drag-select: marquee selects a region,
      // Cmd+A is the "everything" shortcut. Locked elements are
      // excluded (matching the marquee's behavior) since they can't
      // be transformed via the shared Transformer anyway.
      //
      // Skipped when an input/textarea has focus — same gate as undo/
      // redo above — so typing into the text editor field doesn't get
      // hijacked into selecting every canvas element.
      if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !e.shiftKey) {
        e.preventDefault();
        const ids = elementsRef.current
          .filter((el) => !el.locked)
          .map((el) => el.id);
        setSelection(ids);
        return;
      }

      // Arrow-key nudging. 1px per press, 10px with shift held. The nudge
      // goes through `fitElementToBounds` so it stops at the edge of the
      // print area instead of pushing the element out of bounds — same
      // semantics as the drag bound function. We read the current
      // `elements` from a ref so this listener doesn't re-attach on every
      // state change. (Fix for #12.)
      //
      // Suppressed during crop mode — in crop mode the selected image is
      // visually "frozen" beneath the crop overlay and the user is
      // interacting with the crop rect, not the underlying image. Arrow
      // keys moving the image out from under the crop rect would be
      // surprising. The crop rect itself doesn't get arrow-key nudging
      // either right now; the user adjusts it via the on-canvas handles.
      if (selectedId && !cropping && (
        e.key === 'ArrowUp' || e.key === 'ArrowDown'
        || e.key === 'ArrowLeft' || e.key === 'ArrowRight'
      )) {
        const el = elementsRef.current.find((x) => x.id === selectedId);
        if (!el) return;
        e.preventDefault();
        const step = e.shiftKey ? 10 : 1;
        let dx = 0;
        let dy = 0;
        if (e.key === 'ArrowUp') dy = -step;
        else if (e.key === 'ArrowDown') dy = step;
        else if (e.key === 'ArrowLeft') dx = -step;
        else if (e.key === 'ArrowRight') dx = step;
        const proposed = { ...el, x: (el.x ?? 0) + dx, y: (el.y ?? 0) + dy };
        const fitted = fitElementToBounds(proposed, activeCanvasSize);
        updateElement(selectedId, {
          x: fitted.x ?? proposed.x,
          y: fitted.y ?? proposed.y,
        });
      }
    };
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [selectedId, selectedIds, deleteElement, deleteMany, duplicateElement, undo, redo, canUndo, canRedo, activeCanvasSize, updateElement, deselectAll, bringForward, sendBackward, commitHistory, setSelection, cropping, handleApplyCrop]);

  /* ─── Click-outside-to-deselect ───────────────────────────────────────── */
  //
  // The selection should clear when the user clicks empty canvas (the
  // pink frame around the shirt or the shirt itself, anywhere outside the
  // print zone). It should NOT clear when the user clicks the right-side
  // text panel — the previous version of this handler did, which kicked
  // users out of edit mode every time they tried to interact with a
  // textarea, slider, color swatch, or font dropdown.
  //
  // Scoping rules:
  //   • Only run when the click lands INSIDE the canvas-area DOM tree.
  //     Header, sidebar, FAB, mobile sheet, etc. — leave selection alone.
  //   • If the click lands on the floating element toolbar, leave
  //     selection alone (the user is editing the selected thing).
  //   • If the click hit Konva's own <canvas>, do nothing here — Konva's
  //     Stage.onClick / shape.onClick already handle select / deselect for
  //     hits inside the working area.
  //
  // What's left after those skips: clicks on the pink canvas frame, the
  // shirt SVG, or the dashed selection border — i.e. "empty canvas". Those
  // deselect.
  useEffect(() => {
    const handleMouseDown = (e) => {
      // Bail when nothing is selected (single OR multi). Without the
      // multi-select check, this handler would skip deselecting a
      // marquee selection (selectedId is null when multiple are
      // selected) and the user would have no way to clear it by
      // clicking the outer pink frame.
      if (!selectedId && selectedIds.size === 0) return;
      if (!canvasRef.current?.contains(e.target)) return;
      if (toolbarRef.current?.contains(e.target)) return;
      // Zoom controls live inside canvas-area too — clicking +/- shouldn't
      // deselect the active element. (Fix for issue #1.)
      if (zoomRef.current?.contains(e.target)) return;
      // Konva's <canvas> hits get handled by Stage.onClick / shape.onClick;
      // we use a ref to the Stage's container div rather than the loose
      // `target.tagName === 'CANVAS'` check so a future canvas element
      // elsewhere on the page can't accidentally match. (Fix for issue #7.)
      if (stageContainerRef.current?.contains(e.target)) return;
      deselectAll();
    };
    document.addEventListener('mousedown', handleMouseDown);
    return () => document.removeEventListener('mousedown', handleMouseDown);
  }, [selectedId, selectedIds, deselectAll]);

  /* ─── Selection-aware update wrapper ──────────────────────────────────── */
  //
  // For text elements, we apply two transforms before the patch reaches
  // updateElement:
  //
  //   1. fontSizeToPreserveWidth: when the patch changes fontFamily but
  //      not fontSize, derive a fontSize that keeps the rendered bbox
  //      width approximately constant. Wider faces (e.g. a chunky display
  //      font) get scaled DOWN; narrower faces keep the user's chosen size.
  //
  //   2. withTextCenterPreservation: when the patch changes any
  //      bbox-affecting attribute (text, fontFamily, fontSize), recompute
  //      x/y so the text's visual CENTER stays fixed. Without this,
  //      typing into the textarea makes on-canvas text drift right as it
  //      grows; with this, it grows symmetrically around its center.
  //
  // Both transforms are no-ops for non-text elements (or when the patch
  // doesn't trigger them), so this wrapper is safe to use as the universal
  // "update the selected element" path.
  const updateSelectedElement = useCallback((attrs) => {
    if (!selectedId) return;
    let patch = attrs;
    if (selectedElement?.type === 'text') {
      if (patch.fontFamily !== undefined && patch.fontSize === undefined) {
        const newSize = fontSizeToPreserveWidth(
          selectedElement.text ?? '',
          selectedElement.fontFamily ?? 'Pacifico',
          selectedElement.fontSize ?? 32,
          patch.fontFamily,
        );
        patch = { ...patch, fontSize: newSize };
      }
      patch = withTextCenterPreservation(selectedElement, patch);
    }
    updateElement(selectedId, patch);
  }, [selectedId, selectedElement, updateElement]);

  /* ─── Wrap addElement so uploaded photos populate Recent Uploads ──────── */
  //
  // Mobile-only sheet auto-close behavior
  // ─────────────────────────────────────
  // On mobile, the sidebar's add controls (Upload Photo, Stickers,
  // Emoji, Text) live inside the bottom sheet, which covers most of
  // the canvas. When the user successfully adds an element from the
  // sheet, leaving the sheet open hides the freshly-added element
  // behind the sheet — the user has to manually dismiss the sheet to
  // see what they just added. Closing the sheet as part of each add
  // wrapper makes the placement visible immediately.
  //
  // Desktop is unaffected — the sidebar is a docked panel rather than
  // a modal sheet, so there's nothing to close. The `if (isMobile)`
  // gate ensures we don't churn the unused setIsMobileSheetOpen on
  // desktop adds (harmless but pointless).
  //
  // The close goes AFTER addElement so the user briefly sees the
  // sheet's close animation overlap with the new element appearing
  // on the canvas underneath — reads as "the action completed, the
  // sheet is dismissing." Putting it before would feel like "the
  // sheet vanished, then nothing happened, then the element
  // appeared" since add work isn't synchronous all the way through
  // to render.
  const handleAddImage = useCallback((data) => {
    // Bug 2 fix — adding a new element exits text-edit mode. The user's
    // attention is now on the freshly-added element, so leaving the
    // Text tab in edit-mode for some previously-selected text would
    // produce a stale form. Clearing BEFORE addElement is important:
    // addElement also moves selection, which triggers the edit-mode
    // useEffect; clearing first ensures that effect's bail-out branch
    // fires (editingTextElementId already null) instead of the
    // transfer branch.
    setEditingTextElementId(null);
    addElement(data);
    if (data && typeof data.src === 'string') {
      setRecentUploads((prev) => {
        if (prev.some((u) => u.previewUrl === data.src)) return prev;
        const next = [
          ...prev,
          {
            id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
            previewUrl: data.src,
            originalUrl: data.originalUrl,
          },
        ];
        // Keep the list bounded; LRU semantics are fine here.
        return next.slice(-20);
      });
    }
    if (isMobile) setIsMobileSheetOpen(false);
  }, [addElement, isMobile]);

  // Wrappers around addElement that also exit text-edit mode (bug 2
  // fix). Sticker, emoji, and text additions all flow through Sidebar
  // → these wrappers → addElement; the clear-then-add ordering ensures
  // the edit-mode useEffect sees a clean state by the time selection
  // updates to the new element id. Sticker / emoji additions are
  // self-evidently "new element" (the user clicked into a different
  // tab to pick one); text additions matter most for the case where
  // a user was editing text A in the Text tab, then clicked elsewhere
  // to deselect (or used a draft-mode workflow) and pressed "Add text
  // to canvas" — we want the new text to be SELECTED but NOT in
  // edit mode, so the user can immediately style or move it without
  // their next keystroke landing in the textarea.
  //
  // Both wrappers also close the mobile sheet on add — see
  // handleAddImage's docblock above for the full rationale on the
  // ordering and the isMobile gate.
  const handleAddStickerOrEmoji = useCallback((data) => {
    setEditingTextElementId(null);
    addElement(data);
    if (isMobile) setIsMobileSheetOpen(false);
  }, [addElement, isMobile]);
  const handleAddNewText = useCallback((data) => {
    setEditingTextElementId(null);
    addElement(data);
    if (isMobile) setIsMobileSheetOpen(false);
  }, [addElement, isMobile]);

  // Remove a single upload from the Recent Uploads list. Used by the
  // per-thumb "Delete" affordance in UploadTab. The on-disk file on the
  // server isn't touched — only this UI surface is. Re-uploading the
  // same file would add a new entry as if it were fresh.
  const handleRemoveUpload = useCallback((id) => {
    setRecentUploads((prev) => prev.filter((u) => u.id !== id));
  }, []);

  const handleSlotImageUpload = useCallback(async (slotId, imageData) => {
    try {
      const elementData = await assignImageToSlot(slotId, imageData);
      if (elementData) addElement(elementData);
    } catch (err) {
      console.error('Failed to assign image to slot:', err);
      showToast(t('toast.image-load-failed'), 'error');
    }
  }, [assignImageToSlot, addElement, showToast]);

  /* ─── Header actions ──────────────────────────────────────────────────── */
  const handleSave = useCallback(() => {
    if (elements.length === 0) {
      showToast(t('toast.add-something-first'), 'info');
      return;
    }
    exportDesign(elements, 'pawfectly-yours-design', currentTemplate);
  }, [elements, exportDesign, currentTemplate, showToast]);

  // Print-test download. Same export pipeline as Save — useExport's
  // exportDesign already triggers a programmatic browser download once the
  // server responds — but routed through a separate handler so the icon
  // button can have its own semantics:
  //   • No empty-design alert: testing the empty-canvas export path is
  //     itself a useful sanity check on the server pipeline.
  //   • Different filename suffix so test downloads don't collide with
  //     real Save downloads in the user's Downloads folder.
  //   • Re-entrancy guard via the `exporting` flag (the button is also
  //     disabled in the Header) so rapid clicks don't fire parallel POSTs.
  const handleTestDownload = useCallback(() => {
    if (exporting) return;
    exportDesign(elements, 'pawfectly-yours-test', currentTemplate);
  }, [exporting, elements, exportDesign, currentTemplate]);

  const handleShare = useCallback(async () => {
    const url = window.location.href;
    if (navigator.share) {
      try {
        await navigator.share({
          title: 'My Pawfectly Yours design',
          text: 'Check out the shirt I made!',
          url,
        });
      } catch { /* user cancelled */ }
      return;
    }
    if (navigator.clipboard) {
      try {
        await navigator.clipboard.writeText(url);
        showToast(t('toast.link-copied'), 'success');
        return;
      } catch { /* fall through */ }
    }
    // Last-resort fallback: show the URL in a longer-lived toast so the
    // user can read and copy it manually.
    showToast(t('toast.copy-failed', { url }), 'info', 8000);
  }, [showToast]);

  const handleAddToCart = useCallback(() => {
    // Defense in depth: the button is disabled when this is true, but a
    // programmatic call (or a future codepath) shouldn't be able to bypass
    // the bounds check.
    if (outOfBoundsCount > 0) return;
    setCartCount((c) => c + 1);
    if (isMobile) setIsMobileSheetOpen(false);
  }, [isMobile, outOfBoundsCount]);

  const handleOpenCart = useCallback(() => {
    if (cartCount === 0) {
      showToast(t('toast.cart-empty'), 'info');
    } else {
      showToast(
        t('toast.cart-items', {
          count: cartCount,
          plural: cartCount === 1 ? 'item' : 'items',
        }),
        'info',
      );
    }
  }, [cartCount, showToast]);

  /* ─── Clear canvas with two-tap confirmation ───────────────────────── */
  // First click arms the action and shows a toast; the user has
  // CLEAR_CONFIRM_MS to tap again to actually clear. If they don't, the
  // armed state quietly disarms. After confirmation we wipe elements,
  // template, AND localStorage — "clear" should mean clear, not "clear
  // until you reload". Undo still works for in-session restore.
  // (Fix for #35.)
  const handleClear = useCallback(() => {
    if (elements.length === 0 && !currentTemplate) return;
    if (clearArmed) {
      replaceElements([]);
      clearTemplate();
      clearPersistedState();
      setClearArmed(false);
      clearArmTimer.cancel();
      showToast(t('toast.cleared'), 'success');
      return;
    }
    setClearArmed(true);
    showToast(t('toast.clear-confirm'), 'info', CLEAR_CONFIRM_MS);
    clearArmTimer.schedule(() => setClearArmed(false), CLEAR_CONFIRM_MS);
  }, [elements.length, currentTemplate, clearArmed, replaceElements, clearTemplate, showToast, clearArmTimer]);

  /* ─── Print preview ─────────────────────────────────────────────────────── */
  // Opens the modal and triggers an export with autoDownload disabled.
  // The modal reads exporting/error/exportUrl from useExport state and
  // shows a spinner, error, or the rendered image accordingly. The user
  // can dismiss the modal at any time — the in-flight export still
  // completes (and exportUrl ends up populated for any later use, e.g.
  // a follow-up Save). (Fix for #33.)
  const handlePreview = useCallback(() => {
    if (exporting) return;
    if (elements.length === 0) {
      showToast(t('toast.add-something-first'), 'info');
      return;
    }
    setPreviewModalOpen(true);
    exportDesign(elements, 'pawfectly-yours-preview', currentTemplate, { autoDownload: false });
  }, [exporting, elements, exportDesign, currentTemplate, showToast]);

  // Download from inside the preview modal. We don't reuse the export
  // pipeline here — the URL we already have IS the rendered PNG — so a
  // bare anchor click is enough.
  const handlePreviewDownload = useCallback(() => {
    if (!exportUrl) return;
    const link = document.createElement('a');
    link.href = exportUrl;
    link.download = 'pawfectly-yours-preview.png';
    link.click();
  }, [exportUrl]);

  /* ─── Persistence (#28 / #29 / #30) ──────────────────────────────────── */
  //
  // URL-driven template loading + saved-state restoration. Runs once on
  // mount and decides what the canvas should look like based on two
  // inputs:
  //
  //   1. The URL's `?template=X` param (a deep link from the marketing
  //      site). Templates can ONLY be entered via this param now — the
  //      editor's templates tab is gone, by design.
  //   2. The persisted state in localStorage (the user's saved work,
  //      preferences, recents).
  //
  // Decision matrix:
  //
  //   URL has ?template=X (valid id):
  //     a. saved.currentTemplateId === X → user is returning to their
  //        work via the same deep link. Load template X AND restore
  //        their saved elements (preserves customizations).
  //     b. saved.currentTemplateId !== X (or no saved state) → user is
  //        starting fresh on a new template. Load template X and place
  //        its starter elements. Saved elements (if any) belong to a
  //        different design and are discarded.
  //
  //   URL has ?template=X (unknown id) → surface a toast so broken
  //     marketing links are caught in QA, then fall through to the
  //     no-URL-param path.
  //
  //   URL has no ?template= (or value is empty / 'freeform') → restore
  //     saved state as-is. The user opened the editor without a deep
  //     link — they're continuing whatever they were doing.
  //
  // Preferences slice (shirt color, size, recents) is restored
  // unconditionally regardless of which branch we take — those aren't
  // tied to a specific design.
  //
  // The auto-save effect below skips its first run via `isFirstSaveRef`
  // so the initial render doesn't overwrite the user's saved state with
  // the default initial state before this restore effect's setStates
  // have propagated.
  const isFirstSaveRef = useRef(true);
  useEffect(() => {
    const saved = loadPersistedState();
    const urlTemplate = readTemplateFromUrl();

    // Validate URL template, if present. `freeform` is excluded from the
    // "unknown" toast because it's an internal marker meaning "no
    // template" — silently treat as no-param rather than yelling at the
    // user about a label they probably didn't type themselves.
    const urlTemplateValid = !!urlTemplate
      && urlTemplate !== 'freeform'
      && TEMPLATES.some((t) => t.id === urlTemplate);
    if (urlTemplate && urlTemplate !== 'freeform' && !urlTemplateValid) {
      showToast(t('toast.unknown-template', { id: urlTemplate }), 'error');
    }

    // Always restore preferences (shirt color/size, preview-on-model,
    // recents). These are session-level state and don't depend on
    // whether a template is being loaded.
    if (saved) {
      if (saved.shirtColorId) setShirtColorId(saved.shirtColorId);
      if (saved.shirtSize) setShirtSize(saved.shirtSize);
      if (Array.isArray(saved.recentColors)) setRecentColors(saved.recentColors);
      if (Array.isArray(saved.recentFonts)) setRecentFonts(saved.recentFonts);
      if (Array.isArray(saved.recentUploads)) setRecentUploads(saved.recentUploads);
      // Snap preference — typeof check rather than truthy because
      // `false` is a valid saved value ("user previously turned it off").
      if (typeof saved.snapEnabled === 'boolean') setSnapEnabled(saved.snapEnabled);
    }

    if (urlTemplateValid) {
      // Deep link from marketing. Load the named template.
      try { loadTemplate(urlTemplate); } catch { /* ignore */ }
      const matchesSaved = saved?.currentTemplateId === urlTemplate;
      if (matchesSaved && Array.isArray(saved.elements) && saved.elements.length > 0) {
        // User is returning to their work on this template. Their saved
        // elements are valid — keep them.
        replaceElements(saved.elements);
      } else {
        // First time on this template (or coming from a different
        // template). Place the template's starter elements. We mirror
        // handleAddTemplate's auto-fit-to-bounds behavior here so a
        // malformed template doesn't immediately trip the bounds chip
        // on first paint.
        const tmpl = TEMPLATES.find((t) => t.id === urlTemplate);
        const raw = tmpl?.elements ? tmpl.elements.map((el) => ({ ...el })) : [];
        const fitted = fitElementsToBounds(raw, activeCanvasSize);
        replaceElements(fitted);
      }
    } else if (saved) {
      // No URL template (or invalid one). Restore saved state in full.
      if (saved.currentTemplateId && saved.currentTemplateId !== 'freeform') {
        try { loadTemplate(saved.currentTemplateId); } catch { /* ignore */ }
      }
      if (Array.isArray(saved.elements) && saved.elements.length > 0) {
        replaceElements(saved.elements);
      }
    }

    // Initialize undo history. Safe regardless of which branch above
    // ran: if a snapshot was already pushed by replaceElements above,
    // initializeHistory's length-guard makes this a no-op; if not (no
    // saved state, no URL template), it seeds an empty-array floor so
    // the user's first action becomes undoable back to a clean canvas.
    // See the section comment above the keyboard-shortcuts block for
    // the full rationale.
    initializeHistory();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Auto-save on any persisted slice change. Skip the first run so the
  // initial-render state doesn't overwrite the saved blob before the
  // restore effect's state updates have propagated. After that, every
  // change to any persisted slice writes the latest snapshot.
  // `savePersistedState` strips blob: URLs and silently no-ops on
  // storage errors, so this effect is safe to fire freely.
  useEffect(() => {
    if (isFirstSaveRef.current) {
      isFirstSaveRef.current = false;
      return;
    }
    savePersistedState({
      elements,
      shirtColorId,
      shirtSize,
      recentColors,
      recentFonts,
      recentUploads,
      snapEnabled,
      currentTemplateId: currentTemplate?.id ?? null,
    });
  }, [elements, shirtColorId, shirtSize, recentColors, recentFonts, recentUploads, snapEnabled, currentTemplate]);

  /* ─── Zoom ─────────────────────────────────────────────────────────────── */
  const zoomIn  = useCallback(() => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2))), []);
  const zoomOut = useCallback(() => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2))), []);

  /* ─── Photo editor onComplete (preserves C11 blob revoke) ─────────────── */
  const handlePhotoEditComplete = useCallback((url) => {
    if (!editingElement) return;

    const editingId = editingElement.id;
    const previousSrc = editingElement.src;
    const oldWidth = editingElement.width || 100;
    const oldHeight = editingElement.height || 100;

    const revokePrevious = () => {
      revokeBlobUrl(previousSrc);
    };

    const img = new window.Image();
    img.onload = () => {
      const newAspect = img.naturalWidth / img.naturalHeight;
      const oldAspect = oldWidth / oldHeight;

      let newWidth, newHeight;
      if (Math.abs(newAspect - oldAspect) < 0.01) {
        newWidth = oldWidth;
        newHeight = oldHeight;
      } else {
        const area = oldWidth * oldHeight;
        newWidth = Math.sqrt(area * newAspect);
        newHeight = newWidth / newAspect;
      }

      // Preserve the element's visual center across the dimension change.
      // Without this, a crop that changes aspect ratio (e.g. landscape →
      // portrait) keeps the top-left anchor fixed, so the new photo's
      // center drifts — typically off the original position the user had
      // chosen, sometimes outside the print area entirely. Mirrors the
      // text center-preservation logic in updateSelectedElement.
      // (Fix for issue #4.)
      const oldCenterX = (editingElement.x ?? 0) + oldWidth / 2;
      const oldCenterY = (editingElement.y ?? 0) + oldHeight / 2;
      const newX = oldCenterX - newWidth / 2;
      const newY = oldCenterY - newHeight / 2;

      updateElement(editingId, {
        src: url,
        x: Math.round(newX),
        y: Math.round(newY),
        width: Math.round(newWidth),
        height: Math.round(newHeight),
        crop: undefined,
        // The image source has changed, so any in-app pre/post-crop
        // tracking refers to a previous image and is no longer
        // meaningful. Clearing avoids a later Reset Crop firing
        // against stale dimensions.
        preCrop: undefined,
        postCrop: undefined,
      });
      revokePrevious();
      setEditingElement(null);
    };
    img.onerror = () => {
      updateElement(editingId, { src: url });
      revokePrevious();
      setEditingElement(null);
    };
    img.src = url;
  }, [editingElement, updateElement]);

  /* ─── Render ──────────────────────────────────────────────────────────── */

  // The Sidebar is rendered into either the desktop right rail or the mobile
  // bottom sheet — same instance either way. Defining it once preserves its
  // internal state (active tab, etc.) when the viewport flips.
  const sidebarNode = (
    <Sidebar
      onAddImage={handleAddImage}
      onAddSticker={handleAddStickerOrEmoji}
      onAddText={handleAddNewText}
      recentUploads={recentUploads}
      onRemoveUpload={handleRemoveUpload}
      /* Selection-aware text editing: passing `editingTextElement`
       * (NOT `selectedElement`) into the Text tab means SELECTING a
       * text element no longer puts the tab into edit mode. Edit
       * mode is now triggered only by the explicit pencil/Edit-text
       * gesture, which sets editingTextElementId in App. The Text
       * tab stays in draft mode when no edit is active, so opening
       * the tab while text is merely selected shows the "add new
       * text" form rather than hijacking with the selection's
       * details. (Change 7 follow-up.) */
      selectedElement={selectedElement}
      editingTextElement={editingTextElement}
      onUpdateSelected={updateSelectedElement}
      onCommit={commitHistory}
      shirtColorId={shirtColorId}
      onShirtColorChange={setShirtColorId}
      shirtSize={shirtSize}
      onShirtSizeChange={setShirtSize}
      basePrice={SHIRT_BASE_PRICE_USD}
      totalPrice={SHIRT_BASE_PRICE_USD}
      onAddToCart={handleAddToCart}
      cartDisabled={outOfBoundsCount > 0}
      cartDisabledReason={cartDisabledReason}
      recentColors={recentColors}
      onPickColor={addRecentColor}
      recentFonts={recentFonts}
      onPickFont={addRecentFont}
      elements={elements}
      onSelectElement={selectElement}
      onDeleteElement={deleteElement}
      /* Layers-panel per-row actions (Change 4). Duplicate is wired
       * straight through; lock toggles route through updateElement so
       * we get the history entry + selection preservation that other
       * mutations get. */
      onDuplicateElement={duplicateElement}
      onUpdateElement={updateElement}
      /* S3 multi-select wiring — LayersPanel reads these to render
         shift-click toggle, bulk-delete, and HTML5 drag-reorder. The
         single-select consumers above (selectedElement, onSelectElement)
         continue to work unchanged. */
      selectedIds={selectedIds}
      onToggleInSelection={toggleInSelection}
      onDeleteMany={deleteMany}
      onReorderElement={reorderElement}
      /* Controlled active-tab API — lets handleStartTextEdit flip the
       * right rail to Text atomically with setting the editing id. */
      activeTab={sidebarActiveTab}
      onActiveTabChange={setSidebarActiveTab}
      /* Desktop focus signal — bumped on each pencil click so the
       * Text tab can focus its textarea on a fresh click without
       * stealing focus on unrelated re-renders. */
      textEditFocusToken={textEditFocusToken}
    />
  );

  return (
    <div className="editor-shell">
      <OfflineIndicator />
      <PWAInstall />

      <Header
        cartCount={cartCount}
        onSave={handleSave}
        onShare={handleShare}
        onOpenCart={handleOpenCart}
        onOpenMobileMenu={() => setIsMobileSheetOpen(true)}
        onTestDownload={handleTestDownload}
        isExporting={exporting}
        onClear={handleClear}
        canClear={elements.length > 0 || !!currentTemplate}
        onPreview={handlePreview}
      />

      <main className="editor-body">
        <section className="canvas-area" ref={canvasRef}>
          <div className="canvas-area__stage">
            <DesignCanvas
              elements={elements} selectedId={selectedId}
              selectedIds={selectedIds}
              setSelection={setSelection}
              onToggleSelect={toggleInSelection}
              isMobile={isMobile}
              onSelect={selectElement} onDeselect={deselectAll}
              onUpdate={updateElement} onCommit={commitHistory}
              currentTemplate={currentTemplate}
              shirtColor={shirtColorHex}
              zoom={zoom}
              showHint={!hintDismissed && elements.length === 0}
              outOfBoundsCount={outOfBoundsCount}
              stageContainerRef={stageContainerRef}
              shiftHeld={shiftHeld}
              snapEnabled={snapEnabled}
              /* Slot uploads. Previously this flowed through the
                 Templates tab in the sidebar. With the templates tab
                 removed, the canvas's empty slot placeholders are now
                 the click target. DesignCanvas hosts a hidden file
                 input and routes the picked image back through this
                 callback, which already handles auto-cropping the
                 image to the slot's aspect ratio (C8 fix in
                 useTemplate.js). */
              onSlotImageUpload={handleSlotImageUpload}
              onStartTextEdit={handleStartTextEdit}
              /* Crop mode: when non-null, DesignCanvas mounts the
               * <CropOverlay> over the targeted element and the rect-change
               * callback flows live updates back into App state so subsequent
               * renders show the right geometry. App's handleApplyCrop reads
               * the final rect from cropping state at commit time. */
              cropping={cropping ? {
                elementId: cropping.elementId,
                rect: cropping.rect,
                onRectChange: handleCropRectChange,
              } : null}
            />
          </div>

          {/* Bottom bar — toolbar (when an element is selected on desktop)
              on the left, history + zoom/snap controls on the right,
              always pinned to the bottom of canvas-area. Bundling these
              into a single flex row means they can't collide as
              canvas-area gets narrow (the previous implementation had
              each one absolutely positioned independently and they'd
              overlap below ~880px); also gives us a single hook for
              the narrow-screen stacking media query and the mobile
              static-position override. Toasts (export status, app-wide)
              deliberately stay OUTSIDE this bar — they're top-anchored,
              with their own positioning.

              Undo/redo were moved out of the Header in Change 19 and
              now live alongside zoom in this bar. The right-side
              cluster (history + zoom) reads as one visual unit; the
              history pill carries `margin-left: auto` to push both
              pills to the right whether or not the toolbar is present. */}
          <div className="canvas-area__bottom-bar">
            {selectedElement && !isMobile && (
              <div className="canvas-area__toolbar" ref={toolbarRef}>
                <ElementToolbar
                  element={selectedElement}
                  onUpdate={updateSelectedElement}
                  onCommit={commitHistory}
                  onEditPhoto={(el) => setEditingElement(el)}
                  isMobile={false}
                  cropping={!!cropping && cropping.elementId === selectedElement.id}
                  onStartCrop={handleStartCrop}
                  onApplyCrop={handleApplyCrop}
                  onCancelCrop={handleCancelCrop}
                  onUndoCrop={handleUndoCrop}
                />
              </div>
            )}

            {/* Right-side cluster: undo/redo on top, zoom/snap below.
                Stacked vertically so undo/redo gets prime real estate
                and isn't visually buried by the zoom pill the way it
                was when they sat side-by-side. The wrapper carries
                `margin-left: auto` to push the whole cluster to the
                right edge of the bar when the toolbar is present
                (and to the left edge of the bar when it isn't —
                still flush right because the bar itself spans the
                canvas width). The narrow-screen and mobile breakpoints
                flatten this back to a row so the stacked pair doesn't
                eat vertical space when the bar is already in column
                mode. */}
            <div className="canvas-area__right-stack">
              <div className="canvas-area__history">
                <HistoryControls
                  canUndo={canUndo}
                  onUndo={undo}
                  canRedo={canRedo}
                  onRedo={redo}
                />
              </div>

              <div className="canvas-area__zoom" ref={zoomRef}>
                <ZoomControls
                  zoom={zoom}
                  onZoomIn={zoomIn}
                  onZoomOut={zoomOut}
                  min={ZOOM_MIN}
                  max={ZOOM_MAX}
                  snapEnabled={snapEnabled}
                  onToggleSnap={toggleSnap}
                />
              </div>
            </div>
          </div>

          {/* Export feedback toast — bottom of canvas area.
              Auto-dismissed after EXPORT_TOAST_AUTO_DISMISS_MS once
              the success pill is showing (Change 22). exportUrl on
              its own no longer keeps the pill visible — the
              `!exportToastDismissed` gate hides it after the timeout
              fires. The pill manual-dismiss (✕ button) still calls
              clearExport to wipe exportUrl entirely, which makes
              the whole wrap go away via the outer condition. */}
          {(exporting || error || (exportUrl && !exportToastDismissed)) && (
            <div className="export-toast-wrap">
              {exporting && (
                <div className="export-toast export-toast--progress">
                  <div className="spinner-small" /> <span>Saving design… {progress}%</span>
                </div>
              )}
              {!exporting && error && (
                <div className="export-toast export-toast--error">
                  <span>⚠️ Save failed: {error}</span>
                  <button type="button" onClick={clearExport} aria-label="Dismiss" className="export-toast__close">✕</button>
                </div>
              )}
              {!exporting && !error && exportUrl && !exportToastDismissed && (
                <div className="export-toast export-toast--success">
                  <span>✅ Saved!</span>
                  <a href={exportUrl} download className="export-toast__link">Download</a>
                  <button type="button" onClick={clearExport} aria-label="Dismiss" className="export-toast__close">✕</button>
                </div>
              )}
            </div>
          )}

          {/* App-wide toast — replaces ad-hoc alert() calls. Lives at the
              top-center of canvas-area, distinct from the export-toast at
              top-left. Keyed on toast.id so each new message restarts the
              fade-in animation. (Fix for #19.) */}
          {toast && (
            <div className="app-toast-wrap" key={toast.id}>
              <div className={`app-toast app-toast--${toast.kind}`} role="status" aria-live="polite">
                <span>{toast.message}</span>
                <button
                  type="button"
                  className="app-toast__close"
                  onClick={dismissToast}
                  aria-label="Dismiss"
                >✕</button>
              </div>
            </div>
          )}
        </section>

        {/* Right panel — desktop only */}
        {!isMobile && (
          <aside className="right-panel-wrap">
            {sidebarNode}
          </aside>
        )}
      </main>

      {/* Mobile bottom sheet + FAB */}
      {isMobile && (
        <>
          {/* Mobile element toolbar — floats above the canvas when an element is selected */}
          {selectedElement && (
            <div className="mobile-toolbar-wrap" ref={toolbarRef}>
              <ElementToolbar
                element={selectedElement}
                onUpdate={updateSelectedElement}
                onCommit={commitHistory}
                onEditPhoto={(el) => setEditingElement(el)}
                onEditText={(el) => handleStartTextEdit({ id: el.id })}
                isMobile={true}
                cropping={!!cropping && cropping.elementId === selectedElement.id}
                onStartCrop={handleStartCrop}
                onApplyCrop={handleApplyCrop}
                onCancelCrop={handleCancelCrop}
                onUndoCrop={handleUndoCrop}
                /* Layer-control handlers — wired MOBILE ONLY because
                 * the desktop ElementToolbar instance above doesn't
                 * render its Layer controls section (mobile-only by
                 * design; desktop has the LayersPanel in the right
                 * rail with the same per-row actions, plus keyboard
                 * shortcuts Cmd+D, [, ], Backspace covering the same
                 * ground). The handler shapes mirror the keyboard /
                 * LayersPanel paths:
                 *
                 *   • duplicateElement and deleteElement commit
                 *     history internally (see the Cmd+D and
                 *     Backspace branches of the keyboard handler
                 *     above — neither calls commitHistory after).
                 *
                 *   • bringForward and sendBackward do NOT commit
                 *     internally (see the [ / ] branch above, which
                 *     calls commitHistory explicitly after). We
                 *     follow the same pattern here so the user
                 *     gets one history entry per z-order change. */
                onDuplicate={(el) => duplicateElement(el.id)}
                onDelete={(el) => deleteElement(el.id)}
                onBringForward={(el) => { bringForward(el.id); commitHistory(); }}
                onSendBackward={(el) => { sendBackward(el.id); commitHistory(); }}
              />
            </div>
          )}

          {/* Mobile FAB. Hidden when an element is selected AND the sheet
              is closed — the toolbar at the top is enough UI in that case,
              and on narrow landscape phones both surfaces compete for
              the same vertical space. The user can still open the sheet
              via the hamburger menu in the header. When the sheet IS
              open we keep the FAB visible (showing its X icon) so the
              user has a thumb-reachable close action. (Fix for #20.) */}
          {(!selectedElement || isMobileSheetOpen) && (
            <button
              type="button"
              className="app-fab"
              onClick={() => setIsMobileSheetOpen((v) => !v)}
              aria-label={isMobileSheetOpen ? 'Close customization options' : 'Open customization options'}
            >
              <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
                {isMobileSheetOpen ? (
                  <>
                    <path d="M6 6l12 12" />
                    <path d="M18 6L6 18" />
                  </>
                ) : (
                  <>
                    <path d="M12 5v14" />
                    <path d="M5 12h14" />
                  </>
                )}
              </svg>
            </button>
          )}

          <MobileBottomSheet
            open={isMobileSheetOpen}
            onClose={() => setIsMobileSheetOpen(false)}
            // Compact mode when editing text so the canvas remains visible
            // above the sheet. For other interactions (uploading photos,
            // browsing stickers) full height is fine — the user is in a
            // browse mode, not watching the canvas. (Fix for issue #10.)
            compact={selectedElement?.type === 'text'}
          >
            {sidebarNode}
          </MobileBottomSheet>
        </>
      )}

      {editingElement && (
        <PhotoPreEditor
          imageSrc={editingElement.src}
          onComplete={handlePhotoEditComplete}
          onClose={() => setEditingElement(null)}
        />
      )}

      {/* Print preview modal — reads exporting/error/exportUrl from
          useExport state. The export request is fired by handlePreview
          with autoDownload disabled, so the URL lands in exportUrl
          without triggering a download; the modal's own download button
          (handlePreviewDownload) handles that explicitly. (Fix for #33.) */}
      <PreviewModal
        open={previewModalOpen}
        imageUrl={exportUrl}
        loading={exporting}
        error={error}
        onClose={() => setPreviewModalOpen(false)}
        onDownload={handlePreviewDownload}
      />

      {/* Inline text editor removed (Change 7 follow-up). Editing
          now happens exclusively in the right-rail Text tab; the
          previous on-canvas textarea overlay visually swapped the
          styled glyphs for a plain textarea, which read as the text
          "looking different". handleStartTextEdit no longer mounts
          this surface. */}

      {/* Pencil-to-edit affordance — MOBILE ONLY. Renders a small
          floating pencil button next to the selected text node so
          the user can explicitly enter edit mode (which opens the
          bottom sheet on the Text tab). Desktop doesn't use this:
          selecting a text element automatically switches the right
          rail to the Text tab, no extra gesture required. */}
      {isMobile && selectedElement?.type === 'text' && (
        <TextEditAffordance
          element={selectedElement}
          stageContainerRef={stageContainerRef}
          onStartEdit={handleStartTextEdit}
        />
      )}

      {/* Debug overlay — dev-only readouts for diagnosing the
          rotation-pivot jump bug. Off by default; enable via
          `?debug=1` URL param or `localStorage.paw_debug_overlay`.
          See DebugOverlay.jsx for the full rationale and what each
          readout means. The flag is read once at module load so the
          gate is a stable constant — production builds without the
          flag set never even render this subtree. */}
      {DEBUG_OVERLAY_ENABLED && (
        <DebugOverlay
          element={selectedElement}
          stageContainerRef={stageContainerRef}
        />
      )}
    </div>
  );
}

export default App;
