/* WP Site Beam — Image Tools (3-step wizard)
   Step 1 · Select images  (queue / drag-and-drop / from-scan handoff)
   Step 2 · Configure tools (mode + settings + bulk options)
   Step 3 · Process & push  (run optimization, review savings, push to WP)
   Pattern mirrors bulkresizephotos.com — each step is focused, footer nav moves fwd/back. */

function ImageTools() {
  const { useState, useMemo, useRef, useEffect } = React;

  // ---------- Wizard step ----------
  const [step, setStep] = useState(1);      // 1 | 2 | 3
  const steps = [
    { n:1, lbl:'Select images',   sub:'Upload or review queue',   icon:'image' },
    { n:2, lbl:'Configure tools', sub:'Compress · resize · etc.', icon:'sliders' },
    { n:3, lbl:'Process & push',  sub:'Run and send to WordPress', icon:'spark' },
  ];

  // ---------- Config ----------
  const [mode, setMode]   = useState('pipeline');
  const [quality, setQ]   = useState(82);
  const [maxW, setMaxW]   = useState(2560);
  const [format, setFmt]  = useState('jpeg');  // 2026-05-20 default = 'jpeg' (best safe choice for WordPress workflows: universal browser support, predictable behavior, HEIC→JPEG is the most common iPhone import flow). Was 'same' (auto). 'same' moved to last position in UI as advanced option.
  const [target, setTgt]  = useState('acme.co');
  const [autoAlt, setAlt] = useState(false);   // 2026-05-20 SOON, default off
  const [stripExif, setExif] = useState(true);
  const [progressive, setProg] = useState(false); // 2026-05-20 SOON, default off
  const [flattenPng, setFlatten] = useState(false); // 2026-05-20 SOON — PNG no-transparency detection not yet wired in compressOne

  // ---------- Pipeline (Smush-style full chain) ----------
  /* Pipeline stage defaults — 2026-05-20 revised per Jordan feedback.
     Principle: anything that CHANGES the output format defaults OFF.
     User should opt-in to format conversion explicitly.

     Why:
       - Old defaults had toWebp:true, which silently converted every JPG/PNG
         to WebP. That meant "HEIC→JPG" badge in the queue was contradicted
         by .webp file on disk. Format mismatch = trust loss.
       - With these new defaults, pipeline = "shrink + compress, preserve
         format". JPG stays JPG, PNG stays PNG. Predictable.
       - User can still tick "Emit WebP" for the size savings — but they
         actively chose it, and the badge + output format match.
       - keepOriginal only makes sense if toWebp/toAvif on (it's the fallback
         in a <picture> tag). Default off when those are off. */
  /* 2026-05-20 — `pipe` state object removed. Was 7 boolean flags
     (detectPngFlat, resize, compress, toWebp, toAvif, keepOriginal,
     pngLossless) but only toWebp/toAvif were actually wired. The other 5
     were cosmetic checkboxes that did nothing. Replaced by:
     - format radio ('same'|'webp'|'avif') drives output format
     - Quality + Max long edge sliders drive compression/resize
     - flattenPng / progressive / autoAlt remain as SOON advanced toggles
       until backend ops are built */

  // ---------- Queue ----------
  // 2026-05-20 — REAL FILE HANDLING. Was: hardcoded seed array of mock file
  // metadata + a setTimeout in runAll() that pretended to "optimize" by just
  // flipping a status flag. Now: real File objects from drag-drop or file
  // picker, real canvas-based compression, real ZIP download.
  //
  // Queue item shape:
  //   { id, n (name), kb (size KB), w, h, t (type), status, savedKb, src,
  //     _file (original File), _dataUrl (preview), _processedBlob, _processedUrl,
  //     _processedKb, _origKb }
  //
  // Users start with a real upload experience (no mock rows).
  const [queue, setQueue] = useState([]);
  const [running, setRunning] = useState(false);
  const [doneRun, setDoneRun] = useState(false);
  const [dragHot, setDragHot] = useState(false);  // true while file being dragged over drop zone
  const fileInputRef = useRef(null);

  /* 2026-05-20 — showDemo / DEMO_SEED / queue removed.
     Per Jordan: the toggle was useless once the page worked with real
     uploads. The 8-item demo seed (hero-homepage.jpg, team-offsite-2024.jpg,
     etc.) is gone too. Queue table renders `queue` directly throughout. */

  /* URL paste panel — collapsible bulk-import-by-URL flow. */
  const [showUrlPanel, setShowUrlPanel] = useState(false);
  const [urlInput, setUrlInput] = useState('');
  const [urlFetching, setUrlFetching] = useState(false);
  const MAX_URLS_PER_BATCH = 50;

  /* Ingest progress — visible loader for the drop zone area while files are
     being read + (for HEICs) decoded. */
  const [ingest, setIngest] = useState({
    active: false,      // is a batch currently being processed?
    total: 0,           // total files in this batch
    current: 0,         // 1-indexed position
    currentName: '',    // filename being processed right now
    currentStage: '',   // 'reading' | 'converting' | 'analyzing' | ''
  });

  // ---------- File ingestion (real) ----------
  /* Accepted MIME types. SVGs pass through unchanged (canvas can't safely
     re-encode arbitrary SVG; we keep them in the queue for completeness
     and bulk-zip them as-is).

     2026-05-20 — Added HEIC/HEIF (iPhone default since iOS 11, 2017) and BMP
     (old Windows screenshots). HEIC requires a CDN-loaded decoder because
     Chrome/Firefox/Edge can't decode it natively — only Safari can. The
     `heic2any` library handles it (~250KB lazy-loaded only when an actual
     HEIC file is dropped). BMP is browser-native so it just needs to be
     in the accept list. */
  const ACCEPTED_TYPES = [
    'image/jpeg','image/png','image/webp','image/svg+xml','image/avif','image/gif',
    'image/heic','image/heif','image/heic-sequence','image/heif-sequence',
    'image/bmp','image/x-ms-bmp',
  ];
  const HEIC_MIME_TYPES = ['image/heic','image/heif','image/heic-sequence','image/heif-sequence'];
  const MAX_FILE_BYTES = 20 * 1024 * 1024;  /* 20MB hard cap — matches design copy */

  /* iOS sometimes exports HEIC with empty type or application/octet-stream
     (depends on the path: Photos app, Files app, Messages, third-party app).
     Always also check the filename extension as a backstop. */
  function isHeicFile(file) {
    if (HEIC_MIME_TYPES.includes(file.type)) return true;
    const name = (file.name || '').toLowerCase();
    return name.endsWith('.heic') || name.endsWith('.heif');
  }

  /* Lazy-load a HEIC→JPEG converter on first HEIC encounter.

     2026-05-20 (FOURTH attempt; tagged [v4] in error messages so screenshots
     are diagnostically obvious):
       v1 — heic2any@0.0.4 via <script>: loaded; libheif too old, fails on
            modern iPhone HEVC HEICs (ERR_LIBHEIF).
       v2 — libheif-js@1.18.2 via <script>: CJS module; nothing exposed on
            window when loaded as a script tag.
       v3 — heic-to@1.1.13 via <script>: same problem as v2; the npm package
            doesn't ship a UMD build that exposes a window global.
       v4 — heic-to@1.1.13 via dynamic ESM import: use jsdelivr's `+esm`
            endpoint which auto-converts CJS to ESM. This is the supported
            pattern for using npm packages directly in browsers without a
            bundler. `await import('https://cdn.jsdelivr.net/npm/<pkg>/+esm')`
            returns a real ES module the browser can use.

     If this still fails, the only remaining options are:
       - Server-side conversion via a Railway endpoint (most reliable, but
         needs a backend deploy)
       - Manual iPhone setting change (Settings → Camera → Formats →
         Most Compatible) so future photos are saved as JPEG */
  async function loadHeicConverter() {
    if (typeof window.__heicConvertCached === 'function') {
      return window.__heicConvertCached;
    }

    let module = null;
    const attempts = [];

    /* Babel-standalone may or may not handle `await import()` cleanly
       depending on its version/config. To bypass that risk entirely, build
       the dynamic-import function via `new Function()` — the import()
       expression lives inside a string that never goes through Babel; the
       browser compiles it at runtime when the function is invoked. */
    const dynamicImport = new Function('url', 'return import(url)');

    /* Try jsDelivr's +esm endpoint first — its auto-CJS-to-ESM is the most
       reliable for converting traditional npm packages for browser use. */
    try {
      module = await dynamicImport('https://cdn.jsdelivr.net/npm/heic-to@1.1.13/+esm');
    } catch (e) {
      attempts.push(`jsdelivr+esm: ${e.message || e}`);
    }

    /* esm.sh fallback — different infrastructure, similar CJS→ESM conversion. */
    if (!module) {
      try {
        module = await dynamicImport('https://esm.sh/heic-to@1.1.13');
      } catch (e) {
        attempts.push(`esm.sh: ${e.message || e}`);
      }
    }

    if (!module) {
      throw new Error(`[v4] HEIC converter import failed — tried: ${attempts.join(' | ')}`);
    }

    /* heic-to exposes `heicTo` as a named export. Defensive fallbacks for
       default exports or wrapper objects in case +esm wraps it differently. */
    const heicTo = module.heicTo
                || (module.default && module.default.heicTo)
                || (typeof module.default === 'function' ? module.default : null);

    if (typeof heicTo !== 'function') {
      /* eslint-disable no-console */
      console.error('[ImageTools v4] heic-to module loaded but heicTo function not found.');
      console.error('  Module:', module);
      console.error('  Module keys:', Object.keys(module));
      if (module.default) {
        console.error('  Default type:', typeof module.default);
        if (typeof module.default === 'object') {
          console.error('  Default keys:', Object.keys(module.default));
        }
      }
      /* eslint-enable no-console */
      throw new Error(`[v4] heic-to imported but heicTo function not found. Exports: ${Object.keys(module).join(', ') || 'none'}`);
    }

    /* Cache on window so subsequent HEIC files skip the import roundtrip. */
    window.__heicConvertCached = heicTo;
    return heicTo;
  }

  /* Convert a HEIC File → JPEG File using heic-to.

     heic-to's API: heicTo({ blob, type, quality }) → Promise<Blob>
     Type can be 'image/jpeg' or 'image/png'. We use JPEG at high
     intermediate quality (0.92) — the downstream compressOne() canvas
     pass will re-encode to final WebP/JPEG at the user's chosen quality. */
  async function convertHeicToJpeg(file) {
    const heicTo = await loadHeicConverter();
    let result;
    try {
      result = await heicTo({
        blob: file,
        type: 'image/jpeg',
        quality: 0.92,
      });
    } catch (e) {
      /* heic-to throws specific errors like "input is not a HEIC file" or
         decode failures. Surface them with file context + version tag. */
      throw new Error(`[v4] HEIC decode error: ${e.message || e}`);
    }
    /* heic-to returns a single Blob for typical photos, may return array for
       multi-image HEICs (sequences/burst). Take the first frame to match
       iOS Photos behavior. */
    const outBlob = Array.isArray(result) ? result[0] : result;
    if (!outBlob || !(outBlob instanceof Blob)) {
      throw new Error('[v4] HEIC decoder returned empty result');
    }
    /* Wrap blob back into a File so .name carries through the pipeline. */
    const baseName = (file.name || 'image.heic').replace(/\.(heic|heif)$/i, '.jpg');
    return new File([outBlob], baseName, { type: 'image/jpeg' });
  }

  /* classifyType: 'photo' for jpg/png/webp/avif (canvas-processable),
                   'svg' for SVG (pass-through),
                   'gif' for animated images (pass-through, no canvas re-encode). */
  function classifyType(mime) {
    if (mime === 'image/svg+xml') return 'svg';
    if (mime === 'image/gif')     return 'gif';
    return 'photo';
  }

  /* Read a File → DataURL via FileReader. Resolves to data URL string. */
  function fileToDataUrl(file) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload  = () => resolve(fr.result);
      fr.onerror = () => reject(fr.error || new Error('FileReader error'));
      fr.readAsDataURL(file);
    });
  }

  /* Read dimensions of an image data URL via Image() decode. */
  function loadImageDims(dataUrl) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onload  = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
      img.onerror = () => reject(new Error('Image decode failed'));
      img.src = dataUrl;
    });
  }

  /* Add one File to the queue with metadata + preview. */
  async function ingestFile(file) {
    /* HEIC detection BEFORE the MIME accept-list check, since the accept-list
       check uses file.type which may be empty/wrong on iOS exports. We catch
       HEICs by extension here and let the rest go through normal validation. */
    const isHeic = isHeicFile(file);
    const origBytes = file.size;     /* preserved for "savings" math after conversion */
    const origName = file.name;      /* preserved for display + ZIP filename */

    if (!isHeic && !ACCEPTED_TYPES.includes(file.type)) {
      window.wpsbToast?.(`Skipped ${file.name}: type ${file.type || 'unknown'} not supported`, 'warn');
      return null;
    }

    /* HEIC conversion pass — runs once per HEIC file before the regular
       canvas/preview flow. The converted JPEG file replaces `file` for the
       rest of ingestion. We track originalBytes separately so the "before"
       size users see is their actual HEIC upload, not the decompressed JPEG
       (which would be misleading — HEIC encodes more efficiently). */
    let workingFile = file;
    if (isHeic) {
      window.wpsbToast?.(`Converting ${origName} (HEIC → JPEG)…`, 'info');
      try {
        workingFile = await convertHeicToJpeg(file);
      } catch (e) {
        window.wpsbToast?.(`HEIC conversion failed for ${origName}: ${e.message}`, 'err');
        return null;
      }
    }

    const oversize = origBytes > MAX_FILE_BYTES;
    const id = `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    const dataUrl = await fileToDataUrl(workingFile);
    let dims = { w: 0, h: 0 };
    if (workingFile.type !== 'image/svg+xml') {
      try { dims = await loadImageDims(dataUrl); } catch (e) { /* keep zero dims */ }
    }
    return {
      id,
      n: origName,                                    /* show the original filename in UI + ZIP */
      kb: Math.round(origBytes / 1024),                /* show the original size (pre-conversion) */
      w: dims.w,
      h: dims.h,
      t: classifyType(workingFile.type),
      status: oversize ? 'oversize' : 'ready',
      savedKb: 0,
      /* 2026-05-20: source column shows just 'upload' for all dropped files,
         including HEICs. Previously HEICs showed 'upload (heic→jpg)' which
         read like a configured output choice, but it's actually just an
         intermediate browser-decode step (browsers can't display HEIC
         natively). The _wasHeic flag is still set internally so the
         savings estimator + post-run display can use it; the user-visible
         badge moved to a status === 'done' gate (see queue table render). */
      src: 'upload',
      _file: workingFile,                              /* converted JPEG for canvas pipeline */
      _dataUrl: dataUrl,
      _mime: workingFile.type,
      _origKb: Math.round(origBytes / 1024),           /* original HEIC size for "saved" math */
      _wasHeic: isHeic,                                /* tag for debug/badge */
    };
  }

  /* Add multiple files (from picker or drop). */
  async function ingestFiles(fileList) {
    const files = Array.from(fileList || []);
    if (files.length === 0) return;
    window.wpsbToast?.(`Reading ${files.length} file${files.length === 1 ? '' : 's'}…`, 'ok');
    /* Start the visible progress UI — drop zone area swaps to a loader. */
    setIngest({ active: true, total: files.length, current: 0, currentName: '', currentStage: 'reading' });
    const items = [];
    for (let i = 0; i < files.length; i++) {
      const f = files[i];
      /* Detect HEIC up front so the stage label reads accurately — HEIC
         conversion takes 1-3 seconds, normal images are <100ms. */
      const isHeic = /\.(heic|heif)$/i.test(f.name) || /^image\/heic|^image\/heif/.test(f.type);
      setIngest({
        active: true,
        total: files.length,
        current: i + 1,
        currentName: f.name,
        currentStage: isHeic ? 'converting' : 'reading',
      });
      try {
        const item = await ingestFile(f);
        if (item) items.push(item);
      } catch (e) {
        window.wpsbToast?.(`Failed to read ${f.name}: ${e.message}`, 'err');
      }
    }
    /* Clear progress state — drop zone reverts to normal. */
    setIngest({ active: false, total: 0, current: 0, currentName: '', currentStage: '' });
    if (items.length > 0) {
      setQueue(q => [...q, ...items]);
      window.wpsbToast?.(`Added ${items.length} image${items.length === 1 ? '' : 's'} to queue`, 'ok');
    }
  }

  /* Drag-drop handlers — preventDefault on all stages is critical to
     prevent the browser's default behaviour (opening/downloading the
     dropped file). */
  function onDragOver(e)  { e.preventDefault(); e.stopPropagation(); if (!dragHot) setDragHot(true); }
  function onDragEnter(e) { e.preventDefault(); e.stopPropagation(); setDragHot(true); }
  function onDragLeave(e) { e.preventDefault(); e.stopPropagation(); setDragHot(false); }
  function onDrop(e)      {
    e.preventDefault(); e.stopPropagation();
    setDragHot(false);
    const dt = e.dataTransfer;
    if (dt && dt.files && dt.files.length > 0) {
      ingestFiles(dt.files);
    }
  }

  /* Browse button → triggers hidden file input */
  function triggerBrowse() {
    if (fileInputRef.current) fileInputRef.current.click();
  }
  function onPickerChange(e) {
    if (e.target.files && e.target.files.length > 0) {
      ingestFiles(e.target.files);
      e.target.value = '';  /* reset so re-selecting the same file fires onChange again */
    }
  }

  /* ── URL paste / bulk URL import ──────────────────────────────
     fetchUrlAsFile: GET the URL, validate response is an image, derive a
     filename from the URL path, wrap blob → File. Throws meaningful errors.

     CORS is the main failure mode. fetch() without `mode: 'no-cors'` will
     reject if the host doesn't send Access-Control-Allow-Origin. We CAN'T
     use no-cors because it gives us an opaque response (we can't read the
     bytes). So CORS failures are unavoidable client-side — Railway proxy
     endpoint planned for full coverage. */
  async function fetchUrlAsFile(rawUrl) {
    const url = rawUrl.trim();
    if (!url) throw new Error('empty URL');
    /* Validate parseable URL */
    try { new URL(url); } catch (e) { throw new Error('invalid URL'); }
    /* Only http(s) — block file://, data:, javascript:, etc */
    if (!/^https?:/i.test(url)) throw new Error('URL must start with http:// or https://');

    let response;
    try {
      response = await fetch(url, { mode:'cors', credentials:'omit' });
    } catch (e) {
      /* TypeError: Failed to fetch is the classic CORS-or-network failure */
      throw new Error('CORS blocked (host doesn\'t allow browser fetch)');
    }
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const blob = await response.blob();

    /* Validate it's actually an image. Some servers serve images with
       wrong Content-Type (e.g. application/octet-stream), so also check
       the URL extension as fallback. */
    const isImageMime = (blob.type || '').startsWith('image/');
    const looksLikeImage = /\.(jpe?g|png|webp|avif|gif|svg|bmp|heic|heif)(?:\?|#|$)/i.test(url);
    if (!isImageMime && !looksLikeImage) {
      throw new Error(`not an image (got ${blob.type || 'unknown content-type'})`);
    }

    /* Extract filename from URL path */
    let filename = 'image';
    try {
      const parsed = new URL(url);
      const parts = parsed.pathname.split('/').filter(Boolean);
      filename = decodeURIComponent(parts[parts.length - 1] || 'image').split('?')[0];
    } catch (e) {}
    /* Ensure filename has an extension */
    if (!filename.includes('.')) {
      const ext = (blob.type.split('/')[1] || 'jpg').replace('jpeg','jpg').split('+')[0];
      filename = `${filename}.${ext}`;
    }
    return new File([blob], filename, { type: blob.type || 'image/jpeg' });
  }

  /* ingestUrls: parse a textarea of URLs (one per line), fetch each in
     sequence (not parallel — keeps memory bounded for large batches), feed
     successes into ingestFile so HEIC conversion etc. still applies. */
  async function ingestUrls(text) {
    const urls = (text || '').split(/\r?\n/).map(u => u.trim()).filter(Boolean);
    if (urls.length === 0) {
      window.wpsbToast?.('No URLs provided', 'warn');
      return;
    }
    if (urls.length > MAX_URLS_PER_BATCH) {
      window.wpsbToast?.(`Max ${MAX_URLS_PER_BATCH} URLs per batch — process this batch first, then add more`, 'warn');
      return;
    }
    setUrlFetching(true);
    setIngest({ active: true, total: urls.length, current: 0, currentName: '', currentStage: 'fetching' });
    window.wpsbToast?.(`Fetching ${urls.length} URL${urls.length === 1 ? '' : 's'}…`, 'info');

    let succeeded = 0;
    const failures = [];
    const newItems = [];
    for (let i = 0; i < urls.length; i++) {
      const url = urls[i];
      /* Show the URL filename (or last path segment) as the current item. */
      const displayName = (() => {
        try { return new URL(url).pathname.split('/').pop() || url; } catch (e) { return url; }
      })();
      setIngest({
        active: true,
        total: urls.length,
        current: i + 1,
        currentName: displayName.slice(0, 60),
        currentStage: 'fetching',
      });
      try {
        const file = await fetchUrlAsFile(url);
        const item = await ingestFile(file);
        if (item) { newItems.push(item); succeeded++; }
        else failures.push({ url, reason: 'ingest rejected' });
      } catch (e) {
        failures.push({ url, reason: e.message || 'unknown error' });
      }
    }
    if (newItems.length > 0) setQueue(q => [...q, ...newItems]);
    setUrlFetching(false);
    setIngest({ active: false, total: 0, current: 0, currentName: '', currentStage: '' });

    if (succeeded > 0) {
      window.wpsbToast?.(`Added ${succeeded} image${succeeded === 1 ? '' : 's'} from URL${succeeded === 1 ? '' : 's'}`, 'ok');
    }
    if (failures.length > 0) {
      const corsCount = failures.filter(f => /CORS|Failed to fetch/i.test(f.reason)).length;
      if (corsCount === failures.length && corsCount > 0) {
        window.wpsbToast?.(`${failures.length} URL${failures.length === 1 ? '' : 's'} blocked by CORS — host doesn't allow browser fetches. (Server-side proxy coming soon.)`, 'warn');
      } else if (corsCount > 0) {
        window.wpsbToast?.(`${failures.length} URL${failures.length === 1 ? '' : 's'} failed (${corsCount} CORS-blocked, ${failures.length - corsCount} other)`, 'warn');
      } else {
        window.wpsbToast?.(`${failures.length} URL${failures.length === 1 ? '' : 's'} failed`, 'warn');
      }
      /* Detailed log for developer console */
      console.warn('[ImageTools] URL ingest failures:', failures);
    }
  }

  /* estimateItemSavingsKb — projects how many KB an item will save once
     processed, based on current settings + file characteristics. Used to
     populate the "Projected result" panel BEFORE the user clicks Start, so
     they can see expected savings and decide whether to proceed. After the
     item is actually processed, we substitute the real savedKb value.

     Heuristics calibrated against real iPhone HEIC → WebP runs (the
     IMG_2101.HEIC / IMG_8166.HEIC test set showed ~65% reduction at q=78,
     maxW=2560, format=webp). For other source types we use reduction
     factors documented in webm/imagemagick benchmarks for similar settings.

     Conservative — under-promises rather than over-promises. */
  function estimateItemSavingsKb(item, maxWidthSetting, qualitySetting, formatSetting) {
    /* Pass-throughs never save anything */
    if (item.t === 'svg' || item.t === 'gif' || item.status === 'skip') return 0;
    /* Real number for completed items */
    if (item.status === 'done') return item.savedKb;
    /* Demo items already have plausible savings pre-baked */
    if (item._demo) return item.savedKb;

    const sourceKb = item.kb || 0;
    if (sourceKb === 0) return 0;

    const longEdge = Math.max(item.w || 0, item.h || 0);
    const willResize = longEdge > maxWidthSetting && longEdge > 0;

    /* Reduction factors by source type:
       - HEIC: typically iPhone photos at ~12MP. After resize to 2560px +
         re-encode at q≈78 → ~65% reduction observed.
       - PNG: large savings when converted to WebP/JPEG since lossless→lossy
         is inherently smaller.
       - JPEG/WebP/AVIF: only ~25-55% depending on whether resize triggers. */
    let factor;
    if (item._wasHeic)                   factor = willResize ? 0.65 : 0.30;
    else if (item._mime === 'image/png') factor = willResize ? 0.70 : 0.50;
    else                                 factor = willResize ? 0.55 : 0.25;

    /* Quality slider scales: lower quality → more savings.
       q=95 → keep factor as-is; q=78 (default) → factor × 1.05; q=50 → factor × 1.20 */
    const qScale = 1 + Math.max(0, (90 - qualitySetting) / 200);
    factor = Math.min(0.85, factor * qScale);   // hard cap at 85% so we never project impossible numbers

    return Math.round(sourceKb * factor);
  }

  const totals = useMemo(() => {
    const totalKb = queue.reduce((a,x) => a + x.kb, 0);
    /* savedKb mixes real values (for done items) with estimates (for ready/
       oversize items). Pure-estimate runs surface a hasEstimate flag so the
       UI can label the projected numbers as estimates rather than facts. */
    let savedKb = 0;
    let hasEstimate = false;
    let hasReal = false;
    queue.forEach(x => {
      const s = estimateItemSavingsKb(x, maxW, quality, format);
      savedKb += s;
      if (x.status === 'done') hasReal = true;
      else if (x.status === 'ready' || x.status === 'oversize') hasEstimate = true;
    });
    const finalKb = totalKb - savedKb;
    const pct     = Math.round((savedKb / Math.max(totalKb,1)) * 100);
    return {
      totalKb, savedKb, finalKb, pct,
      hasEstimate, hasReal,
      photos:   queue.filter(x => x.t === 'photo').length,
      svgs:     queue.filter(x => x.t === 'svg').length,
      ready:    queue.filter(x => x.status === 'ready').length,
      done:     queue.filter(x => x.status === 'done').length,
      oversize: queue.filter(x => x.status === 'oversize' || x.kb > 20480).length,
    };
  }, [queue, maxW, quality, format]);

  const readyCount = totals.ready;

  /* outputFormat — computed truth about what format ends up on disk.
     2026-05-20 v4: Added JPEG + PNG to the format radio (was Same/WebP/
     AVIF only). HEIC override softened — when format='same' AND queue
     has HEIC files, fall back to WebP. For explicit format picks, honor
     the user's choice even for HEIC sources. */
  const outputFormat = useMemo(() => {
    const queueHasHeic = queue.some(x => x._wasHeic && !x._demo);
    if (format === 'avif')  return 'AVIF';
    if (format === 'webp')  return 'WebP';
    if (format === 'jpeg')  return 'JPEG';
    if (format === 'png')   return 'PNG';
    if (queueHasHeic) return 'WebP for HEIC · same as input for JPG/PNG';
    return 'Same as input';
  }, [format, queue]);

  // ---------- Real compression (canvas-based) ----------
  /* compressOne: takes a queue item and produces a compressed Blob.
     Strategy:
       - SVG / GIF / oversize / skip → pass through original (no canvas re-encode)
       - photo → draw to canvas at maxW long-edge resize, export as
         image/jpeg or image/webp via canvas.toBlob(...) at quality/100.
     We DO NOT re-encode PNG (canvas can output PNG, but the result is
     larger than well-encoded source PNGs — it's not a real PNG optimizer.
     PNGs route through JPEG/WebP unless pipe.pngLossless is true.) */
  function loadImageEl(src) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onload  = () => resolve(img);
      img.onerror = () => reject(new Error('decode failed'));
      img.src = src;
    });
  }
  function canvasToBlob(canvas, mime, q) {
    return new Promise(resolve => {
      try { canvas.toBlob(b => resolve(b), mime, q); }
      catch (e) { resolve(null); }
    });
  }

  async function compressOne(item) {
    /* Pass-through cases */
    if (item.t === 'svg' || item.t === 'gif' || item.status === 'skip') {
      return { blob: item._file, mime: item._mime, kb: item._origKb, w: item.w, h: item.h };
    }
    if (item.status === 'oversize') {
      /* Oversize: still attempt — caller can later choose to skip. We resize
         aggressively to maxW to bring it under control. */
    }
    try {
      const img = await loadImageEl(item._dataUrl);
      let targetW = img.naturalWidth;
      let targetH = img.naturalHeight;
      /* Resize to maxW long edge (only DOWN-size, never upscale) */
      const longEdge = Math.max(targetW, targetH);
      const willResize = longEdge > maxW;
      if (willResize) {
        const ratio = maxW / longEdge;
        targetW = Math.round(targetW * ratio);
        targetH = Math.round(targetH * ratio);
      }

      /* 2026-05-20 (v2 — per Jordan's regression report):

         When I switched pipeline defaults from toWebp:true to toWebp:false
         to preserve original formats honestly, I broke HEIC optimization.
         HEIC inputs are decoded to JPEG at ingest (browser limitation,
         can't display HEIC natively). With "same as input" behavior, the
         output stays JPEG — which is ALWAYS larger than HEIC because JPEG
         is a less efficient format. Result: 2.6MB HEIC → 4.2MB JPEG.

         Fix: HEIC inputs unconditionally output as WebP. WebP is the
         closest in-browser-encodable equivalent to HEIC's modern compression
         and produces sizes ~comparable to (sometimes smaller than) the
         HEIC original. JPG/PNG inputs still respect the user's pipeline
         settings (default = same-as-input pass-through).

         The two cases where canvas re-encoding is counterproductive:
           a. Q=100 with no resize and no format change → re-encoding the
              same format at "lossless" canvas quality almost always
              produces a LARGER file than the original — the source JPEG/PNG
              encoder was usually better than canvas's. Skip entirely.
           b. Q<100 but the re-encode still came out bigger than the input
              — common with already-compressed sources. Use input. */
      /* 2026-05-20 v4 — added explicit JPEG + PNG output options.
         HEIC override softened: previously HEIC always forced WebP.
         Now, when format='same' AND source is HEIC, we fall back to
         WebP (browsers can't write HEIC). For explicit format choices,
         honor the user's pick even for HEIC sources.

         PNG output: canvas.toBlob ignores the quality argument for
         PNG (it's always lossless). The slider has no effect on PNG
         output size. Heads-up shown in UI when user picks PNG. */
      const isHeicFallback = format === 'same' && !!item._wasHeic;
      const wantsWebp = format === 'webp' || isHeicFallback;
      const wantsAvif = format === 'avif';
      const wantsJpeg = format === 'jpeg';
      const wantsPng  = format === 'png';
      let targetMime;
      if      (wantsAvif) targetMime = 'image/avif';
      else if (wantsWebp) targetMime = 'image/webp';
      else if (wantsJpeg) targetMime = 'image/jpeg';
      else if (wantsPng)  targetMime = 'image/png';
      else                targetMime = item._mime; // format === 'same' for non-HEIC
      const willChangeFormat = (targetMime !== item._mime);

      /* Case (a): pure pass-through */
      if (quality === 100 && !willResize && !willChangeFormat) {
        const inputKb = Math.round(item._file.size / 1024);
        return { blob: item._file, mime: item._mime, kb: inputKb, w: targetW, h: targetH };
      }

      const canvas = document.createElement('canvas');
      canvas.width  = targetW;
      canvas.height = targetH;
      const ctx = canvas.getContext('2d');
      ctx.imageSmoothingEnabled = true;
      ctx.imageSmoothingQuality = 'high';
      ctx.drawImage(img, 0, 0, targetW, targetH);

      /* Use the unified targetMime computed above — it already accounts
         for convert mode, pipeline.toWebp/toAvif, and the HEIC→WebP force.
         Previous duplicated logic here missed the HEIC case. */
      const outMime = targetMime;
      const blob = await canvasToBlob(canvas, outMime, quality / 100);
      if (!blob) {
        /* WebP unsupported on this browser → retry JPEG */
        const fallback = await canvasToBlob(canvas, 'image/jpeg', quality / 100);
        /* Case (b) applied to fallback path too. */
        if (fallback && fallback.size > item._file.size && !willChangeFormat && !willResize) {
          const inputKb = Math.round(item._file.size / 1024);
          return { blob: item._file, mime: item._mime, kb: inputKb, w: targetW, h: targetH };
        }
        return { blob: fallback, mime: 'image/jpeg', kb: fallback ? Math.round(fallback.size / 1024) : item._origKb, w: targetW, h: targetH };
      }

      /* Case (b): re-encode produced a bigger file than input AND no format
         change was requested → user gets the input back. No silent bloat. */
      if (blob.size > item._file.size && !willChangeFormat && !willResize) {
        const inputKb = Math.round(item._file.size / 1024);
        return { blob: item._file, mime: item._mime, kb: inputKb, w: targetW, h: targetH };
      }

      return { blob, mime: outMime, kb: Math.round(blob.size / 1024), w: targetW, h: targetH };
    } catch (e) {
      return null;
    }
  }

  /* runAll — real compression loop. Updates queue progressively so UI
     reflects per-item progress instead of one big lump at the end. */
  async function runAll() {
    if (running) return;
    const realItems = queue.filter(x => !x._demo && (x.status === 'ready' || x.status === 'oversize'));
    if (realItems.length === 0) {
      window.wpsbToast?.('Nothing to optimize — add files first', 'warn');
      return;
    }
    setRunning(true); setDoneRun(false);
    if (typeof window.announce === 'function') window.announce(`Optimizing ${realItems.length} images`);

    let processed = 0;
    let totalSaved = 0;
    for (const item of realItems) {
      /* Mark as processing */
      setQueue(q => q.map(x => x.id === item.id ? { ...x, status:'processing' } : x));
      const result = await compressOne(item);
      if (result && result.blob) {
        const savedKb = Math.max(0, item._origKb - result.kb);
        totalSaved += savedKb;
        const url = URL.createObjectURL(result.blob);
        setQueue(q => q.map(x => x.id === item.id ? {
          ...x,
          status: 'done',
          savedKb,
          _processedBlob: result.blob,
          _processedUrl: url,
          _processedMime: result.mime,
          _processedKb: result.kb,
          w: result.w,
          h: result.h,
        } : x));
        processed++;
      } else {
        setQueue(q => q.map(x => x.id === item.id ? { ...x, status:'error' } : x));
        window.wpsbToast?.(`Failed to compress ${item.n}`, 'warn');
      }
    }

    setRunning(false); setDoneRun(true);
    const savedMB = (totalSaved / 1024).toFixed(2);
    window.wpsbToast?.(`Optimized ${processed} image${processed === 1 ? '' : 's'} · saved ${savedMB} MB`, 'ok');
  }

  /* Remove one item by id (was: by name, which broke on duplicate filenames). */
  function remove(id) {
    setQueue(q => {
      const target = q.find(x => x.id === id);
      if (target && target._processedUrl) {
        try { URL.revokeObjectURL(target._processedUrl); } catch (e) {}
      }
      return q.filter(x => x.id !== id);
    });
  }

  /* downloadZip — bundle all processed files into a ZIP and trigger a
     single browser download. Loads JSZip from CDN on demand (same pattern
     as scanner/tab-images.jsx + scanner/core.jsx PDF). */
  async function downloadZip() {
    const doneItems = queue.filter(x => !x._demo && x._processedBlob);
    if (doneItems.length === 0) {
      window.wpsbToast?.('Nothing to download — run optimization first', 'warn');
      return;
    }
    window.wpsbToast?.(`Building ZIP with ${doneItems.length} optimized image${doneItems.length === 1 ? '' : 's'}…`, 'ok');
    try {
      if (typeof window.JSZip === 'undefined') {
        await new Promise((resolve, reject) => {
          const s = document.createElement('script');
          s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
          s.onload = resolve;
          s.onerror = () => reject(new Error('Failed to load JSZip'));
          document.head.appendChild(s);
        });
      }
      const zip = new window.JSZip();
      /* Map output extension by processed MIME so the filename matches */
      const extByMime = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/avif': 'avif', 'image/svg+xml': 'svg', 'image/gif': 'gif' };
      doneItems.forEach(item => {
        const baseName = item.n.replace(/\.[^.]+$/, '');
        const ext = extByMime[item._processedMime] || 'jpg';
        zip.file(`${baseName}.${ext}`, item._processedBlob);
      });
      const blob = await zip.generateAsync({ type:'blob', compression:'STORE' /* images already compressed */ });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `wpsitebeam-optimized-${new Date().toISOString().slice(0,10)}.zip`;
      document.body.appendChild(a);
      a.click();
      setTimeout(() => {
        try { URL.revokeObjectURL(url); } catch (e) {}
        try { document.body.removeChild(a); } catch (e) {}
      }, 1500);
      window.wpsbToast?.(`Downloaded ZIP · ${doneItems.length} image${doneItems.length === 1 ? '' : 's'}`, 'ok');
    } catch (e) {
      window.wpsbToast?.(`ZIP failed: ${e.message}`, 'err');
    }
  }

  /* downloadOne — single-file download for any processed item */
  function downloadOne(item) {
    if (!item._processedBlob) {
      window.wpsbToast?.('This file hasn\'t been optimized yet', 'warn');
      return;
    }
    const extByMime = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/avif': 'avif', 'image/svg+xml': 'svg', 'image/gif': 'gif' };
    const baseName = item.n.replace(/\.[^.]+$/, '');
    const ext = extByMime[item._processedMime] || 'jpg';
    const url = URL.createObjectURL(item._processedBlob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${baseName}-optimized.${ext}`;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      try { URL.revokeObjectURL(url); } catch (e) {}
      try { document.body.removeChild(a); } catch (e) {}
    }, 1500);
  }

  /* pushToWP — Phase 2. Today this just downloads the ZIP since the
     plugin's image-replace write-back op isn't built yet. When it lands
     (MOD-007 T-053), this will switch to enqueueing the ZIP blob to
     the SaaS, which the plugin pulls and unpacks into wp-content/uploads. */
  function pushToWP() {
    window.wpsbToast?.('Plugin push not yet available — downloading ZIP instead. Upload to WP via Media Library bulk import.', 'info');
    downloadZip();
  }

  function reset() {
    /* Clean up object URLs before clearing queue */
    queue.forEach(x => {
      if (x._processedUrl) { try { URL.revokeObjectURL(x._processedUrl); } catch (e) {} }
    });
    setQueue([]);
    setDoneRun(false);
    setStep(1);
    window.wpsbToast?.('Queue cleared — start over', 'ok');
  }

  /* Cleanup object URLs on unmount */
  useEffect(() => {
    return () => {
      queue.forEach(x => {
        if (x._processedUrl) { try { URL.revokeObjectURL(x._processedUrl); } catch (e) {} }
      });
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ---------- Wizard nav ----------
  const canNext = step === 1 ? queue.length > 0
                : step === 2 ? true
                : false;

  function next() { if (step < 3) setStep(step + 1); }
  function back() { if (step > 1) setStep(step - 1); }

  /* 2026-05-20 — `modes` array removed (was: pipeline/compress/resize/convert/upload).
     Step 2 consolidated into a single unified Settings card. Implicit mode
     is always 'pipeline'. State variable still exists for backward-compat
     with Run summary references but never changes. */

  // ---------- Render ----------
  return (
    <div>
      <PageHead crumb="Operations" title="Image Tools"
        sub="Compress, resize, convert, and bulk-upload images to WordPress. Follows a 3-step flow — pick images, configure, then process."
      />
      {/* 2026-05-20 — header actions removed:
          • "Back to Scanner" — was a static nav shortcut that suggested
            Image Tools was downstream of Scanner. It isn't (it's a
            standalone tool). When the "Import from last scan" flow ships
            this will return as a dynamic breadcrumb like
            "← Continue from Scan {scanId}", but only when that scan
            context actually exists.
          • "Start over" — relocated to the top nav row (with Back /
            Configure), since it's a wizard-scoped control, not a
            page-level action. Also gated on (queue.length > 0 || step > 1)
            so it doesn't advertise resetting an already-empty wizard. */}

      {/* Stepper */}
      <div className="card" style={{ marginBottom: 14 }}>
        <div className="card-body" style={{ padding: 14 }}>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 24px 1fr 24px 1fr', gap: 0, alignItems:'stretch' }}>
            {steps.map((s, i) => {
              const state = step === s.n ? 'active' : step > s.n ? 'done' : 'todo';
              const go = () => { if (step > s.n) setStep(s.n); };
              const clickable = step > s.n;
              return (
                <React.Fragment key={s.n}>
                  <button
                    type="button"
                    onClick={go}
                    disabled={!clickable}
                    aria-current={state === 'active' ? 'step' : undefined}
                    style={{
                      display:'flex', alignItems:'center', gap:12, padding:'10px 12px', borderRadius:8,
                      background: state === 'active' ? 'var(--beam-dim)' : 'transparent',
                      border: state === 'active' ? '1px solid var(--beam-dim)' : '1px solid transparent',
                      cursor: clickable ? 'pointer' : 'default', textAlign:'left',
                      color:'var(--text)',
                    }}>
                    <div style={{
                      width: 36, height: 36, borderRadius:'50%',
                      display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0,
                      background: state === 'done'   ? 'var(--green-dim, var(--green-dim))'
                                : state === 'active' ? 'var(--beam)'
                                : 'var(--surface-2)',
                      color: state === 'done'   ? 'var(--green, #22c55e)'
                           : state === 'active' ? '#000'
                           : 'var(--dim)',
                      border: state === 'done' ? '1px solid var(--green-dim)' : 'none',
                      fontFamily:'var(--font-mono)', fontWeight:700,
                    }}>
                      {state === 'done' ? <Icon name="check" size={16}/> : s.n}
                    </div>
                    <div style={{ minWidth: 0 }}>
                      <div style={{ fontSize:'.78rem', fontWeight:600, color: state === 'active' ? 'var(--beam)' : 'var(--text)' }}>
                        {s.lbl}
                      </div>
                      <div style={{ fontSize:'.66rem', color:'var(--dim)', fontFamily:'var(--font-mono)', fontWeight:'var(--fw-mono-small)'}}>{s.sub}</div>
                    </div>
                  </button>
                  {i < steps.length - 1 && (
                    <div style={{ display:'flex', alignItems:'center', justifyContent:'center' }}>
                      <div style={{
                        height: 2, width:'100%',
                        background: step > s.n ? 'var(--green, #22c55e)' : 'var(--border)',
                        transition:'background .2s ease',
                      }}/>
                    </div>
                  )}
                </React.Fragment>
              );
            })}
          </div>
        </div>
      </div>

      {/* 2026-05-20 — step nav row. Sits between the stepper above and
          the step content below. The status text on the left ("STEP X / 3
          · N images ready") reinforces what the stepper just communicated,
          while the buttons on the right (Start over · Back · Configure →)
          give the user wizard controls without scrolling. */}
      <div style={{
        marginBottom: 14, padding: '10px 14px',
        background:'var(--surface-2)',
        border:'1px solid var(--border)',
        borderRadius: 8,
        display:'flex', alignItems:'center', justifyContent:'space-between', gap: 10, flexWrap:'wrap',
      }}>
        <div style={{ fontSize:'.74rem', color:'var(--dim)', fontFamily:'var(--font-mono)', fontWeight:'var(--fw-mono-small)'}}>
          STEP {step} / 3 · {step === 1 ? `${queue.length} image${queue.length === 1 ? '' : 's'} ready` : step === 2 ? 'configure pipeline' : running ? 'running…' : doneRun ? 'complete' : 'ready to run'}
        </div>
        <div style={{ display:'flex', gap: 8 }}>
          {/* 2026-05-20 — Start over relocated from page header to here.
              Only renders when there's actually something to reset:
              either the queue has files, or the user has advanced past
              Step 1. Otherwise the button advertises a no-op. Sits
              before Back so primary controls (Back / Configure) stay
              right-most and visually anchored. */}
          {(queue.length > 0 || step > 1) && (
            <button className="btn btn-ghost btn-sm" onClick={reset} title="Reset wizard — clears the queue and returns to Step 1">
              <Icon name="refresh" size={13}/>Start over
            </button>
          )}
          <button className="btn btn-ghost btn-sm" onClick={back} disabled={step === 1}>
            <Icon name="chevron-left" size={13}/>Back
          </button>
          {step < 3 && (
            <button className="btn btn-primary btn-sm" onClick={next} disabled={!canNext}>
              {step === 1 ? 'Configure →' : 'Review & run →'}
            </button>
          )}
          {step === 3 && !doneRun && (
            <button className="btn btn-primary btn-sm" onClick={runAll} disabled={running}>
              <Icon name={running ? 'activity' : 'spark'} size={13}/>
              {running ? 'Optimizing…' : `Start — ${readyCount} image${readyCount === 1 ? '' : 's'}`}
            </button>
          )}
          {step === 3 && doneRun && (
            <button className="btn btn-primary btn-sm" onClick={downloadZip}>
              <Icon name="download" size={13}/>Download ZIP ({totals.done})
            </button>
          )}
        </div>
      </div>

      {/* ================= STEP 1 — SELECT IMAGES ================= */}
      {step === 1 && (
        /* 2026-05-20 — Step 1 layout reorganized to side-by-side 30/70.
           Drop zone left (compact, just the essentials), Queue right
           (full-height to take advantage of space). Oversize banner is
           rendered ABOVE the grid as a full-width sibling so it has
           prominence when present and doesn't break the grid layout. */
        <>
          {/* Oversize banner — moved out of the grid 2026-05-20 so it gets
              prominence as an alert and doesn't disrupt the 30/70 layout. */}
          {totals.oversize > 0 && (
            <div className="card" style={{ marginBottom: 14, borderColor:'var(--red-dim)', background:'var(--red-dim)' }}>
              <div className="card-body" style={{ display:'flex', gap:14, alignItems:'flex-start', flexWrap:'wrap' }}>
                <div style={{ width:36, height:36, borderRadius:8, background:'var(--red-dim)', color:'var(--red, #ef4444)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
                  <Icon name="warn" size={18}/>
                </div>
                <div style={{ flex:1, minWidth: 260 }}>
                  <div style={{ fontSize:'.88rem', fontWeight:600, marginBottom: 4 }}>
                    {totals.oversize} file{totals.oversize === 1 ? '' : 's'} over the 20MB limit — resize before processing?
                  </div>
                  <div style={{ fontSize:'.74rem', color:'var(--muted)', lineHeight: 1.5, marginBottom: 8, fontWeight:'var(--fw-body-small)'}}>
                    Browser-side compression of very large images is slow and can freeze the tab. Files this big are usually unintentionally huge — camera exports or uncropped uploads. We recommend <strong style={{ color:'var(--text-2)' }}>resizing to 4096px long edge first</strong> (typical web image max), then running the compression pipeline.
                  </div>
                  <div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
                    <button className="btn btn-primary btn-sm" onClick={() => {
                      setQueue(q => q.map(x => x.status === 'oversize' ? { ...x, status:'ready' } : x));
                      window.wpsbToast?.(`${totals.oversize} oversized file${totals.oversize === 1 ? '' : 's'} will be resized to ${maxW}px during processing`, 'ok');
                    }}>
                      <Icon name="sliders" size={12}/>Resize during processing
                    </button>
                    <button className="btn btn-ghost btn-sm" onClick={() => {
                      setQueue(q => {
                        const removed = q.filter(x => x.status === 'oversize');
                        removed.forEach(x => { if (x._processedUrl) { try { URL.revokeObjectURL(x._processedUrl); } catch (e) {} } });
                        return q.filter(x => x.status !== 'oversize');
                      });
                      window.wpsbToast?.('Oversized files removed from queue', 'ok');
                    }}>
                      <Icon name="x" size={12}/>Skip oversized files
                    </button>
                  </div>
                </div>
              </div>
            </div>
          )}

          <div style={{ display:'grid', gridTemplateColumns:'minmax(280px, 30%) 1fr', gap: 14, marginBottom: 14, alignItems:'start' }}>
            <div className="card">
              <div className="card-head">
                <h2 className="card-title">Drop zone</h2>
                <span className="tag beam">{queue.length} IN QUEUE</span>
              </div>
              <div className="card-body">
              {/* Hidden file input — triggered by Browse button + drop fallback.
                  accept covers all supported types. iOS exporters often emit
                  HEIC files with empty/wrong MIME, so we ALSO add explicit
                  .heic/.heif extensions so the picker filter accepts them. */}
              <input
                ref={fileInputRef}
                type="file"
                multiple
                accept="image/jpeg,image/png,image/webp,image/svg+xml,image/avif,image/gif,image/heic,image/heif,image/bmp,.heic,.heif,.bmp"
                onChange={onPickerChange}
                style={{ display:'none' }}
              />

              {/* Real drag-drop zone — preventDefault on every drag stage
                  blocks the browser's "open dropped file" default. Visual
                  feedback when dragHot. Clicking anywhere on the zone
                  triggers the file picker.

                  2026-05-20: while ingest.active, the inner content swaps to
                  a progress view (animated spinner + current file + counter)
                  so users get visible feedback. Toast at bottom-right was
                  the only signal before — too easy to miss. */}
              <div
                onClick={ingest.active ? undefined : triggerBrowse}
                onDrop={ingest.active ? undefined : onDrop}
                onDragOver={ingest.active ? undefined : onDragOver}
                onDragEnter={ingest.active ? undefined : onDragEnter}
                onDragLeave={ingest.active ? undefined : onDragLeave}
                role={ingest.active ? undefined : 'button'}
                tabIndex={ingest.active ? -1 : 0}
                aria-label={ingest.active ? `Processing ${ingest.current} of ${ingest.total}` : 'Drop images here or click to browse'}
                aria-busy={ingest.active}
                onKeyDown={(e) => { if (!ingest.active && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); triggerBrowse(); } }}
                style={{
                  padding:'20px 14px',
                  border: dragHot ? '2px dashed var(--beam)' : ingest.active ? '2px dashed var(--beam-dim)' : '2px dashed var(--border)',
                  borderRadius: 10,
                  textAlign:'center',
                  background: dragHot ? 'var(--beam-dim)' : ingest.active ? 'var(--surface-2)' : 'var(--surface-2)',
                  cursor: ingest.active ? 'progress' : 'pointer',
                  display:'flex', flexDirection:'column', alignItems:'center', gap: 6,
                  transition:'all .15s ease',
                  minHeight: 140,
                  justifyContent:'center',
                }}>
                {ingest.active ? (
                  /* PROGRESS VIEW — replaces drop-zone messaging during batch processing.
                     Spinner + current file + N of M counter + visible progress bar. */
                  <>
                    <div style={{
                      width: 54, height: 54, borderRadius:'50%',
                      background:'var(--beam-dim)',
                      display:'flex', alignItems:'center', justifyContent:'center',
                      color:'var(--beam)', marginBottom: 4,
                      animation:'wpsbdspin 1.2s linear infinite',
                    }}>
                      <Icon name="activity" size={26}/>
                    </div>
                    <div style={{ fontSize:'.95rem', fontWeight:600, color:'var(--text)' }}>
                      {ingest.currentStage === 'fetching' ? 'Fetching URL…' : 'Uploading…'}
                    </div>
                    <div style={{ fontSize:'.74rem', color:'var(--dim)', fontFamily:'var(--font-mono)', maxWidth:'90%', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontWeight:'var(--fw-mono-small)'}}>
                      {ingest.currentName || '…'}{ingest.currentStage === 'converting' && <span style={{ color:'var(--beam)', marginLeft:6 }}>· decoding HEIC</span>}
                    </div>
                    <div style={{ fontSize:'.78rem', color:'var(--beam)', fontWeight:600, marginTop:4 }}>
                      {ingest.current} of {ingest.total}
                    </div>
                    {/* Visible progress bar — width proportional to current/total. */}
                    <div style={{
                      width:'min(420px, 80%)', height:6, background:'var(--surface-3)',
                      borderRadius: 999, overflow:'hidden', marginTop: 6,
                    }}>
                      <div style={{
                        height:'100%',
                        width: `${Math.round((Math.max(ingest.current - 1, 0) / Math.max(ingest.total, 1)) * 100)}%`,
                        background:'var(--beam)',
                        transition:'width .25s ease',
                      }}/>
                    </div>
                    {ingest.currentStage === 'converting' && (
                      <div style={{ fontSize:'.66rem', color:'var(--muted)', fontFamily:'var(--font-mono)', marginTop:6, fontWeight:'var(--fw-mono-small)'}}>
                        First HEIC takes 3–5s while heic-to library loads · subsequent are instant
                      </div>
                    )}
                  </>
                ) : (
                  /* IDLE VIEW — 2026-05-20 compacted for 30%-width column.
                     Was a giant 200+px tall box with multiple paragraphs of
                     copy; now a tight panel that fits the narrow column
                     while still being scannable. Format details moved to
                     small monospace line; iPhone HEIC explainer dropped
                     entirely (HEIC just works, no need to advertise). */
                  <>
                    <div style={{
                      width: 40, height: 40, borderRadius:'50%',
                      background:'var(--beam-dim)',
                      display:'flex', alignItems:'center', justifyContent:'center',
                      color:'var(--beam)', marginBottom: 2,
                    }}>
                      <Icon name="download" size={20}/>
                    </div>
                    <div style={{ fontSize:'.88rem', fontWeight:600 }}>
                      {dragHot ? 'Drop to upload' : 'Drop images here'}
                    </div>
                    <div style={{ fontSize:'.66rem', color:'var(--muted)', fontFamily:'var(--font-mono)', lineHeight:1.4, fontWeight:'var(--fw-mono-small)'}}>
                      JPG · PNG · WebP · HEIC · AVIF · SVG · GIF · BMP
                      <br/>up to <strong style={{ color:'var(--text-2)' }}>20MB each</strong>
                    </div>
                    <div style={{ display:'flex', gap:6, marginTop: 6, flexWrap:'wrap', justifyContent:'center' }}>
                      <button
                        type="button"
                        className="btn btn-ghost btn-sm"
                        onClick={(e) => { e.stopPropagation(); triggerBrowse(); }}>
                        <Icon name="plus" size={12}/>Browse
                      </button>
                      <button
                        type="button"
                        className="btn btn-ghost btn-sm"
                        onClick={(e) => { e.stopPropagation(); setShowUrlPanel(s => !s); }}
                        aria-expanded={showUrlPanel}>
                        <Icon name="link" size={12}/>{showUrlPanel ? 'Hide URLs' : 'Paste URLs'}
                      </button>
                    </div>
                  </>
                )}
              </div>

              {/* Bulk URL import panel — collapsible. Opens when "Paste URLs"
                  clicked. Real fetch with clear CORS error handling. */}
              {showUrlPanel && (
                <div style={{
                  marginTop: 12,
                  padding: 14,
                  background: 'var(--surface-2)',
                  borderRadius: 8,
                  border: '1px solid var(--border)',
                }}>
                  <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom: 8 }}>
                    <div>
                      <strong style={{ fontSize:'.85rem' }}>Bulk URL import</strong>
                      <span style={{ marginLeft:8, fontSize:'.66rem', color:'var(--muted)', fontFamily:'var(--font-mono)', fontWeight:'var(--fw-mono-small)'}}>
                        Max {MAX_URLS_PER_BATCH} per batch
                      </span>
                    </div>
                    <button className="btn btn-ghost btn-sm" onClick={() => setShowUrlPanel(false)} title="Close panel">
                      <Icon name="x" size={12}/>
                    </button>
                  </div>
                  <textarea
                    value={urlInput}
                    onChange={(e) => setUrlInput(e.target.value)}
                    placeholder={'Paste image URLs, one per line:\nhttps://example.com/photo.jpg\nhttps://cdn.example.com/image.png\nhttps://images.unsplash.com/photo-...\n…'}
                    style={{
                      width:'100%',
                      minHeight: 120,
                      padding: 10,
                      fontSize:'.78rem',
                      fontFamily:'var(--font-mono)',
                      background:'var(--surface)',
                      color:'var(--text)',
                      border:'1px solid var(--border)',
                      borderRadius:6,
                      resize:'vertical',
                      boxSizing:'border-box', fontWeight:'var(--fw-mono-small)'}}
                    disabled={urlFetching}
                  />
                  <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10, marginTop:10, flexWrap:'wrap' }}>
                    <div style={{ fontSize:'.66rem', color:'var(--muted)', flex:1, minWidth: 200, fontWeight:'var(--fw-body-small)'}}>
                      <strong style={{ color:'var(--warn)' }}>Note:</strong> some hosts block browser fetches via CORS. Public CDN URLs (S3, Cloudinary, Unsplash, imgix) usually work. WordPress media URLs and many private hosts will fail until our Railway proxy ships.
                    </div>
                    <div style={{ display:'flex', gap:6 }}>
                      <button
                        className="btn btn-ghost btn-sm"
                        onClick={() => { setUrlInput(''); setShowUrlPanel(false); }}
                        disabled={urlFetching}>
                        Cancel
                      </button>
                      <button
                        className="btn btn-primary btn-sm"
                        onClick={() => { ingestUrls(urlInput); setUrlInput(''); }}
                        disabled={urlFetching || !urlInput.trim()}
                        aria-busy={urlFetching}>
                        <Icon name={urlFetching ? 'activity' : 'download'} size={12}/>
                        {urlFetching ? 'Fetching…' : 'Fetch & add to queue'}
                      </button>
                    </div>
                  </div>
                </div>
              )}

              {/* Bottom controls row — sample data toggle + import sources.
                  2026-05-20: was stacked vertically (label marginTop 14 +
                  grid marginTop 12 = ~40px wasted padding plus the disabled
                  buttons themselves stacked another ~80px). Now compact:
                  checkbox + SOON-list pills sit in one tight row. */}
              {/* 2026-05-20 — removed the "Show sample data" checkbox + the
                  4 SOON connector pills (Last scan / WP media / Drive /
                  Dropbox). Per Jordan: demo toggle was useless once real
                  uploads worked, and the SOON pills were just visual noise
                  for features not built yet. Those four connectors will be
                  added to the Browse/URL row when each ships, not as
                  promised-future-features pills. */}

              {/* 2026-05-20 — the old big-button grid for SOON imports (Import
                  from last scan, From WP media, Google Drive, Dropbox) was
                  removed here. Replaced by compact pill row inside the
                  bottom controls row above. Same SOON status, smaller
                  visual footprint, no fake success toasts. */}
            </div>
          </div>

          {/* Queue table — right column of the 30/70 grid. The oversize
              banner that used to sit between drop zone and queue has been
              moved out of the grid to a full-width sibling above (see top
              of the {step === 1} block). */}
          <div className="card">
            <div className="card-head">
              <h2 className="card-title">Queue ({queue.length})</h2>
              <div style={{ display:'flex', gap:6 }}>
                {/* 2026-05-20 — only show count tags when count is > 0.
                    "0 PHOTOS" and "0 SVGS (SKIP)" added no useful signal
                    and were confusing to users. SVG label also clarified
                    from "SKIP" to "VECTOR" — they pass through unchanged
                    because they're already vector-format, not because
                    they're being skipped due to a problem. */}
                {totals.photos > 0 && (
                  <span className="tag" title="Raster photos in your queue — these will be compressed">
                    {totals.photos} {totals.photos === 1 ? 'PHOTO' : 'PHOTOS'}
                  </span>
                )}
                {totals.svgs > 0 && (
                  <span className="tag" title="SVGs pass through unchanged — they're already vector and don't need raster compression">
                    {totals.svgs} {totals.svgs === 1 ? 'SVG' : 'SVGS'} · VECTOR
                  </span>
                )}
                {queue.length > 0 && (
                  <button className="btn btn-ghost btn-sm" onClick={reset}>
                    <Icon name="refresh" size={12}/>Clear all
                  </button>
                )}
              </div>
            </div>
            <div className="card-body" style={{ padding: 0, overflowX:'auto' }}>
              <table className="table" style={{ minWidth: 720 }}>
                <thead>
                  <tr>
                    <th scope="col">File</th>
                    <th scope="col">Type</th>
                    <th scope="col">Source</th>
                    <th scope="col" style={{ textAlign:'right' }}>Size</th>
                    <th scope="col" style={{ textAlign:'right' }}>Dimensions</th>
                    <th scope="col" style={{ textAlign:'center' }}>Status</th>
                    <th scope="col" style={{ textAlign:'right' }}></th>
                  </tr>
                </thead>
                <tbody>
                  {queue.map(x => (
                    <tr key={x.id || x.n} style={x._demo ? { opacity: .65 } : null}>
                      <td style={{ fontWeight:500 }}>
                        <div style={{ display:'flex', alignItems:'center', gap:8 }}>
                          {/* Real preview thumb if we have a data URL, else color chip placeholder */}
                          {x._dataUrl ? (
                            <img src={x._dataUrl} alt="" style={{ width:28, height:28, borderRadius:6, objectFit:'cover', flexShrink:0, background:'var(--surface-3)' }}/>
                          ) : (
                            <div style={{ width:28, height:28, borderRadius:6, background:`linear-gradient(135deg, hsl(${(x.n.length*29)%360},30%,20%), hsl(${(x.n.length*29+40)%360},40%,30%))`, flexShrink:0 }}/>
                          )}
                          <span style={{ fontFamily:'var(--font-mono)', fontSize:'.78rem', fontWeight:'var(--fw-mono-small)'}}>{x.n}</span>
                          {/* HEIC→JPG badge: only after the user has actually
                              run processing in Step 3. Before that, the
                              decode is an internal display-prep step, not
                              a user-configured transformation, so we don't
                              advertise it. Post-run we DO show it because
                              it's part of what happened to the file. */}
                          {x._wasHeic && x.status === 'done' && <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--beam-dim)', color:'var(--beam)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}} title="iPhone HEIC auto-decoded to JPEG at ingest, output format determined by your pipeline settings">HEIC→JPG</span>}
                          {x._demo && <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>DEMO</span>}
                        </div>
                      </td>
                      <td><span className={'tag' + (x.t === 'svg' ? ' beam' : '')}>{x.t.toUpperCase()}</span></td>
                      <td style={{ fontSize:'.72rem', color:'var(--dim)', fontFamily:'var(--font-mono)', fontWeight:'var(--fw-mono-small)'}}>{x.src || 'upload'}</td>
                      <td style={{ textAlign:'right', fontFamily:'var(--font-mono)', fontSize:'.78rem', fontWeight:'var(--fw-mono-small)'}}>
                        {x.status === 'done' && x._processedKb != null ? (
                          <>
                            <span style={{ color:'var(--dim)', textDecoration:'line-through' }}>{(x._origKb/1024).toFixed(2)}</span>
                            {' → '}
                            <span style={{ color:'var(--green)', fontWeight:600 }}>{(x._processedKb/1024).toFixed(2)} MB</span>
                          </>
                        ) : (
                          <>{(x.kb/1024).toFixed(2)} MB</>
                        )}
                      </td>
                      <td style={{ textAlign:'right', fontFamily:'var(--font-mono)', fontSize:'.78rem', color:'var(--dim)', fontWeight:'var(--fw-mono-small)'}}>{x.w}×{x.h}</td>
                      <td style={{ textAlign:'center' }}>
                        {x.status === 'ready'      && <span className="tag warn">READY</span>}
                        {x.status === 'processing' && <span className="tag" style={{ background:'var(--beam-dim)', color:'var(--beam)', border:'1px solid var(--beam-dim)' }}>RUNNING…</span>}
                        {x.status === 'done'       && <span className="tag ok">DONE</span>}
                        {x.status === 'skip'       && <span className="tag">SKIP</span>}
                        {x.status === 'error'      && <span className="tag" style={{ background:'var(--red-dim)', color:'var(--red)', border:'1px solid var(--red-dim)' }}>ERROR</span>}
                        {x.status === 'oversize'   && <span className="tag" style={{ background:'var(--red-dim)', color:'var(--red)', border:'1px solid var(--red-dim)' }}>OVERSIZE</span>}
                      </td>
                      <td style={{ textAlign:'right', whiteSpace:'nowrap' }}>
                        {x.status === 'done' && x._processedBlob && (
                          <button className="btn btn-ghost btn-sm" onClick={() => downloadOne(x)} aria-label={`Download optimized ${x.n}`} title="Download optimized file">
                            <Icon name="download" size={12}/>
                          </button>
                        )}
                        {!x._demo && (
                          <button className="btn btn-ghost btn-sm" onClick={() => remove(x.id)} aria-label={`Remove ${x.n}`} title="Remove from queue">
                            <Icon name="x" size={12}/>
                          </button>
                        )}
                      </td>
                    </tr>
                  ))}
                  {/* Show in-progress placeholder rows for the current
                      batch ingestion. Helps users understand "something is
                      happening" without having to look at the bottom-right
                      toast. Shows current file + N of M counter. */}
                  {ingest.active && (
                    <tr style={{ background:'var(--surface-2)' }}>
                      <td colSpan="7" style={{ padding:'12px 14px' }}>
                        <div style={{ display:'flex', alignItems:'center', gap: 12 }}>
                          <div style={{
                            width: 22, height: 22, borderRadius:'50%',
                            display:'flex', alignItems:'center', justifyContent:'center',
                            color:'var(--beam)',
                            animation:'wpsbdspin 1.2s linear infinite',
                            flexShrink:0,
                          }}>
                            <Icon name="activity" size={14}/>
                          </div>
                          <div style={{ flex:1, minWidth:0 }}>
                            <div style={{ fontSize:'.82rem', fontWeight:500 }}>
                              {ingest.currentStage === 'fetching' ? 'Fetching' : 'Uploading'}
                              {' · '}
                              <span style={{ fontFamily:'var(--font-mono)', color:'var(--text-2)' }}>{ingest.currentName || '…'}</span>
                            </div>
                            <div style={{ fontSize:'.66rem', color:'var(--dim)', fontFamily:'var(--font-mono)', marginTop:2, fontWeight:'var(--fw-mono-small)'}}>
                              {ingest.current} of {ingest.total}{ingest.currentStage === 'converting' ? ' · decoding HEIC' : ''}
                            </div>
                          </div>
                          <span className="tag" style={{ background:'var(--beam-dim)', color:'var(--beam)' }}>PROCESSING</span>
                        </div>
                      </td>
                    </tr>
                  )}
                  {queue.length === 0 && !ingest.active && (
                    <tr><td colSpan="7" style={{ textAlign:'center', padding:30, color:'var(--dim)' }}>
                      No images in queue yet — drop files above or click <em>Browse files</em>.
                    </td></tr>
                  )}
                </tbody>
              </table>
            </div>
          </div>
        </div>
        </>
      )}

      {/* ================= STEP 2 — CONFIGURE ================= */}
      {step === 2 && (
        <>
          {/* 2026-05-20 — removed standalone Preview panel.
              Was rendering 8 gradient-placeholder cards that were misleading
              (users thought they were seeing actual compressed output).
              Compression has no visual preview to show — you only know the
              result after processing. Replaced with a numeric Projected
              Result panel inline below. */}
          <div className="card" style={{ marginBottom: 14 }}>
            <div className="card-head">
              <h2 className="card-title">Projected result</h2>
              <span className="tag">{queue.length} {queue.length === 1 ? 'IMAGE' : 'IMAGES'}</span>
            </div>
            <div className="card-body">
              {queue.length === 0 ? (
                <div style={{ padding:'20px 14px', textAlign:'center', color:'var(--dim)', fontSize:'.82rem' }}>
                  No images queued. Go back to Step 1 to upload.
                </div>
              ) : (
                <div className="grid grid-3" style={{ gap: 12 }}>
                  <div className="stat">
                    <div className="stat-lbl">Input total</div>
                    <div className="stat-val">{(totals.totalKb/1024).toFixed(2)}<span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:4 }}>MB</span></div>
                  </div>
                  <div className="stat">
                    <div className="stat-lbl">Estimated output</div>
                    <div className="stat-val ok">
                      {(() => {
                        /* Rough estimate: photos compress to (quality/100)^0.6 * 40% of original at WebP.
                           SVGs pass through. Conservative ballpark — actual result shown in Step 3. */
                        const photoBytes = queue.filter(x => x.t === 'photo').reduce((a,x) => a + x.kb, 0);
                        const otherBytes = queue.filter(x => x.t !== 'photo').reduce((a,x) => a + x.kb, 0);
                        const compressionFactor = Math.pow(quality / 100, 0.6) * 0.45;
                        const estKb = (photoBytes * compressionFactor) + otherBytes;
                        return (estKb / 1024).toFixed(2);
                      })()}<span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:4 }}>MB</span>
                    </div>
                  </div>
                  <div className="stat">
                    <div className="stat-lbl">Estimated savings</div>
                    <div className="stat-val" style={{ color:'var(--green)' }}>
                      ~{(() => {
                        const photoBytes = queue.filter(x => x.t === 'photo').reduce((a,x) => a + x.kb, 0);
                        const otherBytes = queue.filter(x => x.t !== 'photo').reduce((a,x) => a + x.kb, 0);
                        const compressionFactor = Math.pow(quality / 100, 0.6) * 0.45;
                        const estKb = (photoBytes * compressionFactor) + otherBytes;
                        return Math.max(0, Math.round((1 - estKb / Math.max(totals.totalKb, 1)) * 100));
                      })()}%
                    </div>
                  </div>
                </div>
              )}
            </div>
          </div>
          {/* 2026-05-20 — Step 2 fully consolidated. Previously had:
              (1) "Pick a tool" card with 5 mode buttons
                  (Smart Pipeline / Compress / Resize / Convert / Bulk upload),
              (2) Settings card that switched its UI based on selected mode,
                  with duplicate Resize/Compress stage checkboxes that just
                  mirrored the sliders.
              Now: one unified Settings card. Mode is implicitly always
              "Smart Pipeline" — sliders + format radio + advanced toggles.
              Bulk upload mode is gone (push-to-WP belongs in Step 3 when
              that backend op ships). Dead checkboxes (detectPngFlat,
              resize, compress, keepOriginal, pngLossless) removed — they
              were never wired in compressOne. */}
          <div className="card" style={{ marginBottom: 14 }}>
            <div className="card-head">
              <h2 className="card-title">Settings</h2>
              <span className="tag">{queue.length} {queue.length === 1 ? 'IMAGE' : 'IMAGES'} SELECTED</span>
            </div>
            <div className="card-body">
              {/* TOP — sliders side-by-side, prominent treatment */}
              <div className="grid grid-2" style={{ gap: 16, marginBottom: 18 }}>
                <div style={{
                  padding:'14px 16px',
                  background:'var(--surface-2)',
                  border:'1px solid var(--border)',
                  borderRadius: 8,
                }}>
                  <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom: 8 }}>
                    <label htmlFor="it-quality" style={{ fontSize:'.82rem', fontWeight:600, color:'var(--text)' }}>
                      Quality
                    </label>
                    <span style={{ fontFamily:'var(--font-mono)', fontSize:'.95rem', color:'var(--beam)', fontWeight:600 }}>
                      {quality}
                      {quality === 100 && <span style={{ marginLeft:4, fontSize:'.6rem', color:'var(--dim)' }}>LOSSLESS</span>}
                      {quality >= 78 && quality <= 86 && <span style={{ marginLeft:4, fontSize:'.6rem', color:'var(--green)' }}>RECOMMENDED</span>}
                      {quality < 60 && <span style={{ marginLeft:4, fontSize:'.6rem', color:'var(--warn)' }}>LOW</span>}
                    </span>
                  </div>
                  <input id="it-quality" type="range" min="40" max="100" step="1" value={quality} onChange={e => setQ(+e.target.value)} style={{ width:'100%' }}/>
                  <div style={{ fontSize:'.66rem', color:'var(--muted)', fontFamily:'var(--font-mono)', marginTop:6, fontWeight:'var(--fw-mono-small)'}}>
                    40 = tiny/blurry · 85 = recommended · 100 = lossless
                  </div>
                </div>
                <div style={{
                  padding:'14px 16px',
                  background:'var(--surface-2)',
                  border:'1px solid var(--border)',
                  borderRadius: 8,
                }}>
                  <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom: 8 }}>
                    <label htmlFor="it-maxw" style={{ fontSize:'.82rem', fontWeight:600, color:'var(--text)' }}>
                      Max long edge
                    </label>
                    <span style={{ fontFamily:'var(--font-mono)', fontSize:'.95rem', color:'var(--beam)', fontWeight:600 }}>
                      {maxW}px
                    </span>
                  </div>
                  <input id="it-maxw" type="range" min="1280" max="4096" step="64" value={maxW} onChange={e => setMaxW(+e.target.value)} style={{ width:'100%' }}/>
                  <div style={{ fontSize:'.66rem', color:'var(--muted)', fontFamily:'var(--font-mono)', marginTop:6, fontWeight:'var(--fw-mono-small)'}}>
                    Images larger than this get downscaled · aspect ratio preserved
                  </div>
                </div>
              </div>

              {/* MIDDLE — Output format radio. 2026-05-20 v2:
                  Now 5 options (was 3): Same / JPEG / PNG / WebP / AVIF.
                  Per Jordan request, .jpg and .png are now explicit
                  choices (previously only Same / WebP / AVIF).
                  Layout switched from stacked vertical list to a
                  responsive grid (3-across on desktop, wraps narrower).
                  HEIC override behavior softened: HEIC now defaults to
                  WebP only when format='same' (since browsers can't
                  write HEIC back). When user picks an explicit format,
                  their choice is honored even for HEIC sources. */}
              <div style={{ marginBottom: 18 }}>
                <label style={{ fontSize:'.68rem', color:'var(--dim)', fontFamily:'var(--font-mono)', letterSpacing:'.08em', textTransform:'uppercase', display:'block', marginBottom:10, fontWeight:'var(--fw-mono-small)'}}>
                  Output format
                </label>
                <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(170px, 1fr))', gap: 8 }}>
                  {[
                    /* 2026-05-20 (v3): JPEG promoted to default + first slot.
                       WordPress workflows overwhelmingly favor JPEG (universal
                       compatibility, predictable behavior). HEIC iPhone imports
                       almost always want JPEG out. WebP shown second as the
                       modern recommendation. "Same as input" demoted to last
                       slot — it's an advanced pass-through option that
                       confuses new users when their HEIC stays HEIC-ish (HEIC
                       can't be re-encoded so it falls back to WebP anyway). */
                    { v:'jpeg', lbl:'JPEG (.jpg)',   hint:'Recommended · best for photos · widely supported', wired:true },
                    { v:'webp', lbl:'WebP',          hint:'~30% smaller · modern browsers',       wired:true },
                    { v:'png',  lbl:'PNG (.png)',    hint:'Lossless · supports transparency',     wired:true },
                    { v:'avif', lbl:'AVIF',          hint:'~50% smaller · newest format',         wired:false },
                    { v:'same', lbl:'Same as input', hint:'Advanced · pass-through · .jpg→.jpg',  wired:true },
                  ].map(opt => {
                    const active = format === opt.v;
                    return (
                      <label key={opt.v} style={{
                        padding:'10px 12px', borderRadius: 6,
                        background: active ? 'var(--surface-3)' : 'var(--surface-2)',
                        border: active ? '1px solid var(--beam-dim)' : '1px solid var(--border)',
                        display:'flex', gap:8, alignItems:'flex-start',
                        cursor: opt.wired ? 'pointer' : 'not-allowed',
                        opacity: opt.wired ? 1 : 0.65,
                        minHeight: 64,
                      }}>
                        <input
                          type="radio"
                          name="it-format"
                          value={opt.v}
                          checked={active}
                          disabled={!opt.wired}
                          onChange={e => setFmt(e.target.value)}
                          style={{ marginTop: 3, flexShrink:0 }}
                        />
                        <div style={{ flex:1, minWidth:0 }}>
                          <div style={{ fontSize:'.78rem', fontWeight: active ? 600 : 500, color: active ? 'var(--beam)' : 'var(--text)', display:'flex', alignItems:'center', gap:5, flexWrap:'wrap' }}>
                            {opt.lbl}
                            {!opt.wired && <span style={{ fontSize:'.55rem', padding:'1px 4px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>SOON</span>}
                          </div>
                          <div style={{ fontSize:'.66rem', color:'var(--dim)', lineHeight:1.35, marginTop:2, fontWeight:'var(--fw-body-small)'}}>{opt.hint}</div>
                        </div>
                      </label>
                    );
                  })}
                </div>
                {queue.some(x => x._wasHeic) && format === 'same' && (
                  <div style={{ marginTop: 8, padding:'8px 10px', background:'var(--beam-dim)', border:'1px solid var(--beam-dim)', borderRadius: 6, fontSize:'.7rem', color:'var(--text-2)', lineHeight:1.4 }}>
                    <strong style={{ color:'var(--beam)' }}>Note:</strong> HEIC inputs will output as WebP since browsers can't write HEIC back. Pick an explicit format above to override.
                  </div>
                )}
                {/* 2026-05-20 (v3) — HEIC+JPEG+high-quality warning.
                    HEIC uses HEVC (4K video codec) which is ~30-50% more
                    efficient than JPEG. Converting HEIC → JPEG at quality
                    90+ ALMOST ALWAYS produces a larger file because
                    you're moving to a less efficient codec while asking
                    the encoder to preserve every detail. Users hit this
                    when they import iPhone photos and expect compression.
                    The fix is quality 75-85 which gives meaningful
                    savings while staying visually indistinguishable. */}
                {queue.some(x => x._wasHeic) && format === 'jpeg' && quality >= 90 && (
                  <div style={{ marginTop: 8, padding:'8px 10px', background:'var(--warn-dim, rgba(237,137,54,.15))', border:'1px solid var(--warn, #ed8936)', borderRadius: 6, fontSize:'.72rem', color:'var(--text-2)', lineHeight:1.4 }}>
                    <strong style={{ color:'var(--warn)' }}>Heads up:</strong> HEIC → JPEG at quality {quality} will likely produce <em>larger</em> files than the original. iPhone HEIC uses a more efficient codec than JPEG, so high-quality JPEG re-encoding needs more bytes for the same visual fidelity. <strong>Drop quality to 80–85</strong> for meaningful reduction with no visible difference.
                  </div>
                )}
                {format === 'png' && queue.some(x => x.t === 'photo') && (
                  <div style={{ marginTop: 8, padding:'8px 10px', background:'var(--surface-2)', border:'1px solid var(--border)', borderRadius: 6, fontSize:'.7rem', color:'var(--muted)', lineHeight:1.4 }}>
                    <strong style={{ color:'var(--text-2)' }}>Heads up:</strong> PNG is lossless and typically produces larger files than JPEG/WebP for photos. Quality slider is ignored for PNG. Best for graphics with transparency.
                  </div>
                )}
              </div>

              {/* BOTTOM — Advanced options. Only Strip EXIF is wired right
                  now; rest are honest SOON until backend ops ship. */}
              <div>
                <label style={{ fontSize:'.68rem', color:'var(--dim)', fontFamily:'var(--font-mono)', letterSpacing:'.08em', textTransform:'uppercase', display:'block', marginBottom:10, fontWeight:'var(--fw-mono-small)'}}>
                  Advanced options
                </label>
                <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
                  <label className="opt-check" title="Canvas re-encoding strips EXIF automatically when this is on. When off, original file's EXIF passes through unchanged.">
                    <input type="checkbox" checked={stripExif} onChange={e => setExif(e.target.checked)}/>
                    <span>Strip EXIF &amp; geolocation metadata</span>
                  </label>
                  <label className="opt-check" style={{ opacity: .7 }} title="Detects PNGs without transparency and re-encodes them as JPG (~70% smaller). Requires alpha-channel scan during ingest — not yet implemented.">
                    <input type="checkbox" checked={flattenPng} onChange={e => setFlatten(e.target.checked)} disabled/>
                    <span style={{ display:'flex', alignItems:'center', gap:6 }}>
                      Auto-flatten PNGs without transparency
                      <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>SOON</span>
                    </span>
                  </label>
                  <label className="opt-check" style={{ opacity: .7 }} title="Requires Brain AI backend integration — coming with plugin write-back op (MOD-007 T-053)">
                    <input type="checkbox" checked={autoAlt} onChange={e => setAlt(e.target.checked)} disabled/>
                    <span style={{ display:'flex', alignItems:'center', gap:6 }}>
                      Auto-generate alt text via Brain AI
                      <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>SOON</span>
                    </span>
                  </label>
                  <label className="opt-check" style={{ opacity: .7 }} title="Browser canvas can't emit progressive JPEGs natively — requires WASM-MozJPEG. Currently always baseline.">
                    <input type="checkbox" checked={progressive} onChange={e => setProg(e.target.checked)} disabled/>
                    <span style={{ display:'flex', alignItems:'center', gap:6 }}>
                      Progressive JPEG encoding
                      <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>SOON</span>
                    </span>
                  </label>
                  {/* 2026-05-20 (v3) — Pro Compression signals v2.0 differentiator.
                      Browser canvas.toBlob() uses native browser JPEG encoder
                      which is ~30-40% less efficient than mozjpeg at same
                      visual quality. v2.0 ships @squoosh/lib WASM bundle (same
                      engines Google uses): mozjpeg for JPEG, oxipng for PNG,
                      libwebp + libavif for next-gen. Zero server cost — runs
                      entirely in-browser. Premium feature for paid plans. */}
                  <label style={{ display:'flex', alignItems:'center', gap:8, fontSize:'.8rem', color:'var(--dim)', cursor:'not-allowed', fontWeight:'var(--fw-body-small)'}}>
                    <input type="checkbox" disabled style={{ accentColor:'var(--beam)' }}/>
                    <span style={{ display:'flex', alignItems:'center', gap:6 }}>
                      Pro Compression engine (mozjpeg / oxipng / libwebp)
                      <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>SOON</span>
                    </span>
                  </label>
                </div>

                <div style={{ marginTop: 14, padding:'10px 12px', background:'var(--beam-dim)', border:'1px solid var(--beam-dim)', borderRadius: 6, fontSize:'.72rem', color:'var(--text-2)', lineHeight:1.5, fontWeight:'var(--fw-body-small)'}}>
                  <strong style={{ color:'var(--beam)' }}>How this works:</strong> images larger than the max edge are downscaled first, then re-encoded at your chosen quality and format. Browser canvas does the work — nothing leaves your computer until you opt to push the results to WordPress (Step 3).
                </div>
              </div>
            </div>
          </div>

        </>
      )}

      {/* ================= STEP 3 — PROCESS & PUSH ================= */}
      {step === 3 && (
        <>
          <div className="grid grid-2 grid-stretch" style={{ gap: 14, marginBottom: 14 }}>
            <div className="card">
              <div className="card-head">
                <h2 className="card-title">{doneRun ? 'Optimization result' : 'Projected result'}</h2>
                {doneRun && <span className="tag ok">OPTIMIZED</span>}
              </div>
              <div className="card-body">
                <div className="grid grid-2 grid-stretch" style={{ gap: 10 }}>
                  <div className="stat">
                    <div className="stat-lbl">Before</div>
                    <div className="stat-val">{(totals.totalKb/1024).toFixed(1)}<span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:4 }}>MB</span></div>
                  </div>
                  {/* 2026-05-20 — pre-run stats now show "—" instead of estimates.
                      Estimates were technically valid but visually identical to
                      real measurements, which read like "your run is done" before
                      you'd clicked Start. Placeholder dashes make it obvious that
                      these numbers only become real after the run completes. */}
                  <div className="stat">
                    <div className="stat-lbl">After</div>
                    <div className="stat-val ok">
                      {doneRun
                        ? <>{(totals.finalKb/1024).toFixed(1)}<span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:4 }}>MB</span></>
                        : <span style={{ color:'var(--dim)' }}>—</span>}
                    </div>
                  </div>
                  <div className="stat">
                    <div className="stat-lbl">Saved</div>
                    <div className="stat-val ok">
                      {doneRun
                        ? <>{(totals.savedKb/1024).toFixed(1)}<span style={{ fontSize:'.7rem', color:'var(--dim)', marginLeft:4 }}>MB</span></>
                        : <span style={{ color:'var(--dim)' }}>—</span>}
                    </div>
                  </div>
                  <div className="stat">
                    <div className="stat-lbl">Reduction</div>
                    <div className="stat-val ok">
                      {doneRun
                        ? <>{totals.pct}%</>
                        : <span style={{ color:'var(--dim)' }}>—</span>}
                    </div>
                  </div>
                </div>
                <div style={{ marginTop:12, padding:'10px 12px', background:'var(--beam-dim)', border:'1px solid var(--beam-dim)', borderRadius:6, fontSize:'.76rem', color:'var(--beam)', fontWeight:'var(--fw-body-small)'}}>
                  {totals.photos} photo{totals.photos === 1 ? '' : 's'} will be processed · {totals.svgs} SVG{totals.svgs === 1 ? '' : 's'} skipped (already optimal)
                  {!doneRun && totals.photos > 0 && (
                    <div style={{ marginTop:6, fontSize:'.68rem', color:'var(--muted)', fontFamily:'var(--font-mono)', fontWeight:'var(--fw-mono-small)'}}>
                      Click <strong>Start</strong> to optimize and see real savings — typical reduction is 40–70% depending on quality + format settings.
                    </div>
                  )}
                </div>
              </div>
            </div>

            <div className="card">
              <div className="card-head"><h2 className="card-title">Run summary</h2></div>
              <div className="card-body">
                <dl style={{ display:'grid', gridTemplateColumns:'max-content 1fr', gap:'6px 14px', margin:0, fontSize:'.78rem', fontWeight:'var(--fw-body-small)'}}>
                  {/* 2026-05-20 — Mode row removed (was 'PIPELINE' always
                      after Step 2 consolidation, so it added no signal).
                      Quality + Max width now always shown since pipeline
                      is the only mode. */}
                  <dt style={{ color:'var(--dim)' }}>Quality</dt>
                  <dd style={{ margin:0, fontFamily:'var(--font-mono)' }}>
                    {quality}
                    {quality === 100 && <span style={{ marginLeft:6, fontSize:'.62rem', color:'var(--dim)', fontWeight:'var(--fw-body-small)'}}>LOSSLESS</span>}
                    {quality >= 78 && quality <= 86 && <span style={{ marginLeft:6, fontSize:'.62rem', color:'var(--green)', fontWeight:'var(--fw-body-small)'}}>RECOMMENDED</span>}
                    {quality < 60 && <span style={{ marginLeft:6, fontSize:'.62rem', color:'var(--warn)', fontWeight:'var(--fw-body-small)'}}>LOW</span>}
                  </dd>
                  <dt style={{ color:'var(--dim)' }}>Max long edge</dt>
                  <dd style={{ margin:0, fontFamily:'var(--font-mono)' }}>{maxW}px</dd>
                  <dt style={{ color:'var(--dim)' }}>Output format</dt>
                  <dd style={{ margin:0, fontFamily:'var(--font-mono)' }}>
                    {outputFormat}
                    {outputFormat === 'Same as input' && <span style={{ marginLeft:6, fontSize:'.62rem', color:'var(--dim)', fontWeight:'var(--fw-body-small)'}}>(.jpg stays .jpg, .png stays .png)</span>}
                  </dd>
                  {/* 2026-05-20: Target row removed from pre-run summary.
                      "Push to WP" is SOON-disabled so Target was misleading —
                      implied images would land on acme.co when actually
                      only the ZIP download works right now. Will return
                      post-run once push-to-WP ships, gated on actual push
                      completion (not just config). */}
                  <dt style={{ color:'var(--dim)' }}>Strip EXIF</dt>
                  <dd style={{ margin:0 }}>{stripExif ? 'Yes' : 'No · EXIF preserved'}</dd>
                  {/* autoAlt + progressive removed from summary 2026-05-20 — those
                      features are SOON-disabled in the UI, so showing them as "Yes"
                      here was misleading. They'll come back when the backend wiring
                      ships. */}
                </dl>

                <div style={{ display:'flex', flexDirection:'column', gap:8, marginTop:14 }}>
                  <button className="btn btn-primary" onClick={runAll} disabled={running || readyCount === 0} aria-busy={running}>
                    <Icon name={running ? 'activity' : 'spark'} size={14}/>
                    {running ? 'Optimizing…' : doneRun ? 'Re-run optimization' : `Start — optimize ${readyCount} image${readyCount === 1 ? '' : 's'}`}
                  </button>
                  {/* 2026-05-20 — Push to WP not yet wired (plugin op pending);
                      primary action is "Download ZIP" which actually works.
                      pushToWP() falls back to downloadZip() with explanatory toast. */}
                  <button className="btn btn-ghost" onClick={downloadZip} disabled={totals.done === 0}>
                    <Icon name="download" size={13}/>Download optimized ZIP ({totals.done} ready)
                  </button>
                  <button className="btn btn-ghost btn-sm" onClick={pushToWP} disabled style={{ opacity:.55, cursor:'not-allowed' }}
                          title="Coming soon — requires WPSiteBeam plugin op for image bulk-replace">
                    <Icon name="spark" size={12}/>Push to {target} <span style={{ fontSize:'.58rem', marginLeft:4, padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', letterSpacing:'.05em', fontWeight:'var(--fw-body-small)'}}>SOON</span>
                  </button>
                </div>
              </div>
            </div>
          </div>

          {/* Results table */}
          <div className="card">
            <div className="card-head">
              <h2 className="card-title">Per-file results</h2>
              <div style={{ display:'flex', gap:6 }}>
                <span className="tag ok">{totals.done} DONE</span>
                <span className="tag warn">{totals.ready} PENDING</span>
              </div>
            </div>
            <div className="card-body" style={{ padding: 0, overflowX:'auto' }}>
              <table className="table" style={{ minWidth: 720 }}>
                <thead>
                  <tr>
                    <th scope="col">File</th>
                    <th scope="col" style={{ textAlign:'right' }}>Before</th>
                    <th scope="col" style={{ textAlign:'right' }}>After</th>
                    <th scope="col" style={{ textAlign:'right' }}>Savings</th>
                    <th scope="col" style={{ textAlign:'center' }}>Status</th>
                  </tr>
                </thead>
                <tbody>
                  {queue.map(x => {
                    /* 2026-05-20 (v3) — when processing completes, show the
                       OUTPUT filename based on _processedMime. Previously
                       the table always showed x.n (input filename), which
                       confused users converting HEIC → JPEG because the
                       row still said "IMG_2101.HEIC" even though the ZIP
                       contained "IMG_2101.jpg". Now: show input name pre-
                       run, "input.ext → output.ext" post-run when format
                       changed. Same ext both sides = no transformation
                       arrow, less visual noise. */
                    const extByMime = { 'image/jpeg':'jpg', 'image/png':'png', 'image/webp':'webp', 'image/avif':'avif', 'image/svg+xml':'svg', 'image/gif':'gif' };
                    const baseName = (x.n || '').replace(/\.[^.]+$/, '');
                    const inExt    = ((x.n || '').match(/\.([^.]+)$/) || [])[1] || '';
                    const outExt   = x._processedMime ? (extByMime[x._processedMime] || inExt) : inExt;
                    const renamed  = x.status === 'done' && outExt && outExt.toLowerCase() !== inExt.toLowerCase();
                    const displayName = renamed ? `${baseName}.${outExt}` : x.n;
                    return (
                    <tr key={x.id || x.n} style={x._demo ? { opacity: .65 } : null}>
                      <td style={{ fontWeight:500 }}>
                        <div style={{ display:'flex', alignItems:'center', gap:8, flexWrap:'wrap' }}>
                          {x._dataUrl ? (
                            <img src={x._dataUrl} alt="" style={{ width:24, height:24, borderRadius:4, objectFit:'cover', flexShrink:0, background:'var(--surface-3)' }}/>
                          ) : (
                            <div style={{ width:24, height:24, borderRadius:4, background:`linear-gradient(135deg, hsl(${(x.n.length*29)%360},30%,20%), hsl(${(x.n.length*29+40)%360},40%,30%))`, flexShrink:0 }}/>
                          )}
                          {renamed ? (
                            <span style={{ display:'inline-flex', alignItems:'center', gap:4, flexWrap:'wrap' }}>
                              <span style={{ fontFamily:'var(--font-mono)', fontSize:'.72rem', fontWeight:'var(--fw-mono-small)', color:'var(--dim)', textDecoration:'line-through'}}>{x.n}</span>
                              <span style={{ fontFamily:'var(--font-mono)', fontSize:'.66rem', color:'var(--beam)', fontWeight:'var(--fw-mono-small)'}}>→</span>
                              <span style={{ fontFamily:'var(--font-mono)', fontSize:'.76rem', fontWeight:'var(--fw-mono-small)', color:'var(--green)'}}>{displayName}</span>
                            </span>
                          ) : (
                            <span style={{ fontFamily:'var(--font-mono)', fontSize:'.76rem', fontWeight:'var(--fw-mono-small)'}}>{displayName}</span>
                          )}
                          {x._demo && <span style={{ fontSize:'.58rem', padding:'1px 5px', borderRadius:3, background:'var(--surface-3)', color:'var(--dim)', fontWeight:'var(--fw-body-small)'}}>DEMO</span>}
                        </div>
                      </td>
                      <td style={{ textAlign:'right', fontFamily:'var(--font-mono)', fontSize:'.76rem', fontWeight:'var(--fw-mono-small)'}}>{((x._origKb || x.kb)/1024).toFixed(2)} MB</td>
                      <td style={{ textAlign:'right', fontFamily:'var(--font-mono)', fontSize:'.76rem', fontWeight:'var(--fw-mono-small)'}}>
                        {x.status === 'skip' ? <span style={{ color:'var(--dim)' }}>—</span>
                          : x.status === 'done' && x._processedKb != null ? `${(x._processedKb/1024).toFixed(2)} MB`
                          : x.status === 'done' ? `${(((x._origKb||x.kb) - x.savedKb)/1024).toFixed(2)} MB`
                          : <span style={{ color:'var(--dim)' }}>—</span>}
                      </td>
                      <td style={{ textAlign:'right', fontFamily:'var(--font-mono)', fontSize:'.76rem', fontWeight:'var(--fw-mono-small)'}}>
                        {x.savedKb > 0
                          ? <span style={{ color:'var(--green)' }}>−{(x.savedKb/1024).toFixed(2)} MB</span>
                          : <span style={{ color:'var(--dim)' }}>—</span>}
                      </td>
                      <td style={{ textAlign:'center' }}>
                        {x.status === 'ready'      && <span className="tag warn">PENDING</span>}
                        {x.status === 'processing' && <span className="tag" style={{ background:'var(--beam-dim)', color:'var(--beam)' }}>RUNNING</span>}
                        {x.status === 'done'       && <span className="tag ok">DONE</span>}
                        {x.status === 'skip'       && <span className="tag">SKIP</span>}
                        {x.status === 'error'      && <span className="tag" style={{ background:'var(--red-dim)', color:'var(--red)' }}>ERROR</span>}
                        {x.status === 'oversize'   && <span className="tag" style={{ background:'var(--red-dim)', color:'var(--red)' }}>OVERSIZE</span>}
                      </td>
                    </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          </div>
        </>
      )}

      {/* 2026-05-20 — bottom footer nav removed. Replaced by the top-of-page
          nav row below the PageHead. See top of return() for the new
          location. */}
    </div>
  );
}

window.ImageTools = ImageTools;
