/* app.jsx — Postcard Designer chrome (top bar, inspector, stage, export modal) */

const { useState, useEffect, useRef, useMemo, useCallback } = React;

/* Wait for every <img> AND inline SVG <image> inside `root` to finish
   loading before snapshotting with html2canvas. Without this, the SVG
   logo and uploaded photos can render as blank rectangles when capture
   runs ahead of decode. SVG <image> needs its own check because the
   browser loads it through a separate code path from <img>; querying
   for both keeps the postcard renderer honest as we move photos onto
   inline-SVG mounts (better html2canvas fidelity than background-image). */
async function ensureImagesLoaded(root) {
  if (!root) return;
  const imgs    = [...root.querySelectorAll('img')];
  const svgImgs = [...root.querySelectorAll('svg image')];
  // Use img.decode() instead of polling .complete. The legacy
  // .complete+.naturalWidth check has a race window when src has just
  // been swapped: .complete reads TRUE briefly (showing the previous
  // image's decoded bitmap) before the browser starts loading the
  // new src. That race is exactly what was making the version-B
  // print capture grab version-A's photos on multi-week drips.
  // decode() resolves only when the CURRENT src is fully decoded
  // into a paint-ready bitmap, so html2canvas gets the right pixels.
  const waitImg = (img) => {
    // Skip decode-wait for imgs without a real src (placeholders)
    if (!img.src || img.src === 'about:blank') return Promise.resolve();
    // decode() can reject on broken images — swallow that, the
    // load/error fallback below catches it too.
    return Promise.race([
      img.decode().catch(() => {}),
      new Promise((resolve) => setTimeout(resolve, 8000)),
    ]);
  };
  // SVG <image> readiness: the easiest cross-browser check is to read
  // the href / xlink:href and decode it through a temporary Image()
  // — once that resolves the SVG image is rasterizable too.
  const waitSvgImg = (el) => {
    const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
    if (!href) return Promise.resolve();
    return new Promise((resolve) => {
      const probe = new Image();
      probe.onload  = () => resolve();
      probe.onerror = () => resolve();
      probe.src = href;
      setTimeout(resolve, 8000);
    });
  };
  await Promise.all([
    ...imgs.map(waitImg),
    ...svgImgs.map(waitSvgImg),
  ]);
}

const DEFAULT_STATE = {
  variant: 'pattern',
  side: 'front',
  productType: '',  /* shutters | shades | drapery | exterior — chosen first */
  category: '',     /* specific product, only set after user picks one */
  /* split address fields — all blank by default; recommended text shows as placeholders */
  lastName: '',
  street: '',
  neighborhood: '',
  city: '',                     // state abbreviation — NC or SC; only used in city mode
  /* localeMode — 'neighborhood' (default) renders just the neighborhood line;
     'city' renders "<City>, <ST>" so we also surface the state field. */
  localeMode: 'neighborhood',
  /* copy */
  headline: "We just made your neighbor's windows beautiful.",
  // Subhead default is intentionally empty so the front-card copy
  // can route off the campaign location (Charlotte-metro vs NMB) at
  // render time. See the `s.subhead || (isNmbCampaign(s) ? … : …)`
  // fallbacks in postcards.jsx. Hardcoding the Charlotte string here
  // was leaking onto NMB campaigns.
  subhead: '',
  backHeadline: 'Did you see what your neighbor did?',
  body: "Your neighbors {LOCALE} just refreshed their home with custom window treatments. We'd love to do the same for you — book a consultation and we'll bring the samples to your living room, or visit one of our showrooms below.",
  /* quote — verbatim from the installer's report */
  quote: 'We finally feel like we are living in the home we always wanted.',
  installDate: 'install day',    // e.g. 'April 14'
  /* "Just Sold" variant — hero is the listing photo, renders are the
     three Gemini mock-ups from the window-counter visualizer. All four
     can be auto-populated via URL fragment from the visualizer's
     "Send to Postcard Designer" button. */
  jsTagline: 'Thought about your windows?',
  jsSubTagline: 'We have.',
  jsEyebrow: 'Welcome home',
  // Treatment labels — caption that appears below/over each face's render.
  // heroLabel  → caption under the front hero photo (e.g., "WOVEN WOOD SHADES")
  // backLabel  → caption tag over the back photo (e.g., "ROLLER SHADES")
  // Could be the same product on both faces, or different.
  heroLabel:  '',
  backLabel:  '',
  // (Legacy thumbnail slots — kept for back-compat with older imports;
  // the new just-sold flow uses heroSrc + backPhotoSrc only.)
  renderSrc1: '',
  renderSrc2: '',
  renderSrc3: '',
  renderLabel1: 'Woven Wood',
  renderLabel2: 'Shutters',
  renderLabel3: 'Roman + Drape',
  /* admin-controlled offer */
  offerOn: true,
  promoOn: false,
  promoText: 'First Motor Free when buying 4 or more motorized shades',
  /* photos — two slots only: front + back. If back is empty, front is used on both sides. */
  heroSrc: '',
  heroPosition: '',
  backPhotoSrc: '',
  /* showrooms — all four always print; order is overridden at submit time
     by the mapper's return-address picker (matched showroom first). The
     default order is just the fallback when the designer is opened directly. */
  showroomIds: ['southpark', 'cornelius', 'matthews', 'myrtle'],
  /* contact + print */
  phone: '704-541-1200',
  url: 'asacharlotte.com',
  printMarks: false,
  dropCode: 'ASA-NB-2025-04-CR3219',
};

/* normalize street to Title Case so pasted ALL-CAPS (or messy case) reads cleanly */
function toTitleCase(str) {
  return str.replace(/[A-Za-z][A-Za-z']*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
}

/* HTML-escape user input before injecting into body via dangerouslySetInnerHTML */
function escapeHtml(s) {
  return String(s)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

// Variant-specific copy. The standard "neighbor" variants use the original
// neighbor-targeted body. Just-Sold uses the buyer-audience copy that the
// visualizer handoff sets in state. Switching variants in the topbar should
// always show the right copy regardless of what's stored in localStorage —
// so we never read s.body / s.backHeadline blindly.
const NEIGHBOR_BACK_HEADLINE = 'Did you see what your neighbor did?';
// Privacy: never name the family or print a street number. We credit the
// install by street NAME only (e.g. "up on Cloister Dr"), and fall back to
// "in your neighborhood" if no street is set.
const NEIGHBOR_BACK_BODY     = "Your neighbors {LOCALE} just refreshed their home with custom window treatments. We'd love to do the same for you — book a consultation and we'll bring the samples to your living room, or visit one of our showrooms below.";
const JUSTSOLD_BACK_HEADLINE = 'Welcome to the neighborhood.';
const JUSTSOLD_BACK_BODY     = "A designer on our team took a peek at your listing photos and couldn't resist mocking up a few window-treatment ideas. Set up a one-on-one design consultation at one of our showrooms to walk through all the options in person.";

/* expand placeholders in body */
function expand(s) {
  // Offer block: "Complimentary · Design Consultation · 704-541-1200" for
  // every variant, unless the user has explicitly turned on `promoOn` (then
  // "Featuring · {promoText}"). Per design feedback the swatch override is
  // gone — we ignore any stale state.offerEyebrow / state.offerText.
  const offerEyebrow = s.promoOn ? 'Featuring' : 'Complimentary';
  const offerText = s.promoOn
    ? (s.promoText || POSTCARD_DEFAULT_OFFER_TEXT)
    : POSTCARD_DEFAULT_OFFER_TEXT;
  // Locale is street-name-only (no house number) on the back body. Reads
  // as "up on Cloister Dr" instead of the prior "at 1234 Cloister Dr". If
  // we don't have a street, fall back to the neighborhood.
  const streetOnly = window.postcardStreetName ? window.postcardStreetName(s.street) : '';
  const localeHtml = streetOnly
    ? `up on <strong style="color:var(--asa-navy);font-weight:600">${escapeHtml(streetOnly)}</strong>`
    : (s.neighborhood ? `in <strong style="color:var(--asa-navy);font-weight:600">${escapeHtml(s.neighborhood)}</strong>` : 'nearby');
  /* If lastName is empty, drop the parenthetical "{FAMILY_LC}," so the sentence still reads cleanly. */
  const familyLc = s.lastName
    ? `the ${s.lastName}${/(s|x|z|ch|sh)$/i.test(s.lastName) ? 'es' : 's'}`
    : '';
  const familyUc = s.lastName
    ? `The ${s.lastName}${/(s|x|z|ch|sh)$/i.test(s.lastName) ? 'es' : 's'}`
    : '';
  // Variant-aware body + headline. The state may have stale copy from a
  // previous import (e.g., the user imported a Just-Sold and then flipped
  // back to a neighbor variant in the topbar) — we always use the variant's
  // canonical copy so the right message renders.
  const isJustSold = s.variant === 'justSold';
  const backHeadline = isJustSold ? JUSTSOLD_BACK_HEADLINE : NEIGHBOR_BACK_HEADLINE;
  let bodyTemplate   = isJustSold ? JUSTSOLD_BACK_BODY     : NEIGHBOR_BACK_BODY;
  if (!s.lastName) {
    /* collapse ", {FAMILY_LC}," (the appositive) — handles the default copy without leaving a stray comma */
    bodyTemplate = bodyTemplate.replace(/,\s*\{FAMILY_LC\}\s*,/g, ',');
  }
  // Showroom list — order driven by the return-address pick. NMB
  // campaigns show only NMB on the back; everyone else shows all 4
  // with the matched one in the top-left.
  const ids = (s.showroomIds || []).slice();
  // Body copy pluralization: when the campaign shows only one
  // showroom (NMB), the phrase "one of our showrooms" reads weird —
  // swap to singular versions. Applies to both Neighbor and Just
  // Sold templates without per-variant branching.
  if (ids.length <= 1) {
    bodyTemplate = bodyTemplate
      .replace(/visit one of our showrooms below/gi, 'visit our showroom below')
      .replace(/one of our showrooms/gi, 'our showroom')
      .replace(/at our showrooms/gi, 'at our showroom');
  }
  /* If this is an exterior product, swap to a copy of the headline tuned to outdoor projects. */
  const isExterior = POSTCARD_CATEGORY_TYPE_OF[s.category] === 'exterior';
  const headline = isExterior
    ? "We just made your neighbor's outdoor space more livable."
    : s.headline;
  return {
    ...s,
    headline,
    backHeadline,                  // variant-aware (overrides any stored value)
    body: bodyTemplate
      .replaceAll('{LOCALE}', localeHtml)
      .replaceAll('{NEIGHBORHOOD}', escapeHtml(s.neighborhood || s.street || 'your neighborhood'))
      .replaceAll('{STREET}', `<strong style="color:var(--asa-navy);font-weight:600">${escapeHtml(s.street || '')}</strong>`)
      .replaceAll('{CITY}', escapeHtml(s.city || ''))
      .replaceAll('{FAMILY_LC}', escapeHtml(familyLc))
      .replaceAll('{FAMILY}', escapeHtml(familyUc))
      // Wedding-invite ampersand: " & " → stylized span (after expansion so
      // it covers user-typed copy AND any merged family/locale strings)
      .replace(/ &amp; | & /g, '<span class="amp"> &amp; </span>'),
    offerEyebrow,
    offerText,
    showrooms: ids.length ? ids : ['southpark', 'cornelius', 'matthews', 'myrtle'],
  };
}

/* =========== Sub-components =========== */

function TopBar({ state, set, onExport, wizardMode, capturing }) {
  /* Required-field gate for export. Centralized so the PDF + Send to Printer
     buttons share the same validation and toast copy.
     Just-Sold variant skips all field validation — everything is auto-filled
     from the visualizer hand-off and the audience is the new homeowner. */
  const missing = [];
  if (state.variant !== 'justSold') {
    if (!state.category) missing.push('product');
    // Last name is no longer required (and no longer printed on the card —
    // privacy). We still keep the input around for now in case the field
    // returns, but the export gate must not block on it.
    if (!state.neighborhood.trim()) missing.push(state.localeMode === 'city' ? 'city' : 'neighborhood');
    if (state.localeMode === 'city' && state.city !== 'NC' && state.city !== 'SC') missing.push('state');
  }
  // Just-Sold has no required fields — the audience is the new homeowner
  // and everything is auto-filled from the visualizer hand-off. Even if a
  // field is empty, let them advance.
  const blocked = missing.length > 0;
  const blockedMsg = `Fill in required fields before exporting: ${missing.join(', ')}.`;
  return (
    <div className="topbar">
      <div className="topbar__brand">
        <span className="mark">A</span>
        <span className="name">A Shade Above<span>·</span>Postcard Designer</span>
      </div>
      <div className="topbar__center" style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
        <div className="seg" role="tablist" aria-label="Side">
          <button role="tab" aria-pressed={state.side === 'front'} onClick={() => set({ side: 'front' })}>Front</button>
          <button role="tab" aria-pressed={state.side === 'back'}  onClick={() => set({ side: 'back' })}>Back</button>
        </div>
        <div className="seg" role="tablist" aria-label="Postcard design">
          <button role="tab" aria-pressed={state.variant === 'pattern'}  onClick={() => set({ variant: 'pattern' })}  title="Neighbor postcard — for households near a recently installed home">Neighbor</button>
          <button role="tab" aria-pressed={state.variant === 'justSold'} onClick={() => set({ variant: 'justSold' })} title="Just-sold postcard — for the new homeowner">Just Sold</button>
        </div>
      </div>
      <div className="topbar__right">
        <button
          className="iconbtn"
          aria-pressed={state.printMarks}
          onClick={() => set({ printMarks: !state.printMarks })}
          title="Toggle print marks"
        >
          <span style={{
            display: 'inline-block', width: 8, height: 8,
            border: '1px dashed currentColor',
          }} />
          Print Marks
        </button>
        <button
          className="iconbtn"
          onClick={() => {
            if (blocked) {
              window.dispatchEvent(new CustomEvent('asa-toast', { detail: blockedMsg }));
              return;
            }
            window.print();
          }}
          title="Open print dialog — prints front and back as 11.25×6.25 in pages"
          disabled={blocked}
          style={blocked ? { opacity: 0.45, cursor: 'not-allowed' } : undefined}
        >PDF</button>
        <button
          className="iconbtn iconbtn--primary"
          onClick={() => {
            if (blocked) {
              window.dispatchEvent(new CustomEvent('asa-toast', { detail: blockedMsg }));
              return;
            }
            onExport();
          }}
          disabled={blocked || capturing}
          style={(blocked || capturing) ? { opacity: 0.45, cursor: 'not-allowed' } : undefined}
        >
          {wizardMode
            ? (capturing ? 'Rendering…' : 'Continue to Recipients ▸')
            : 'Send to Printer ▸'}
        </button>
      </div>
    </div>
  );
}

function Section({ num, title, children }) {
  return (
    <div className="section">
      <h3 className="section__head">
        <span className="num">{num}</span>
        {title}
      </h3>
      {children}
    </div>
  );
}

function LockedTag() {  return (
    <span style={{
      display: 'inline-block', marginLeft: 8,
      fontFamily: 'var(--ff-sans)', fontSize: 8.5, fontWeight: 600,
      letterSpacing: '0.18em', textTransform: 'uppercase',
      color: 'var(--asa-stone-500)',
      border: '1px solid var(--asa-stone-300)',
      padding: '1px 6px', borderRadius: 2,
      verticalAlign: 'middle',
    }}>Locked</span>
  );
}

function Field({ label, children }) {
  return (
    <div className="field">      <label className="field__label">{label}</label>
      {children}
    </div>
  );
}

function VarCard({ v, on, onClick }) {
  // Tiny visual proxy for each variation
  const thumb = v.id === 'photo' ? (
    <>
      <div style={{ position: 'absolute', inset: 0, background: 'var(--asa-navy)' }} />
      <div style={{ position: 'absolute', left: 24, top: 0, bottom: 0, right: 0, background: 'linear-gradient(135deg, #b89976, #6c5240)' }} />
    </>
  ) : v.id === 'pattern' ? (
    <>
      <div style={{ position: 'absolute', inset: 0, background: 'var(--asa-off-white)' }} />
      <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 5, backgroundImage: 'url(assets/patterns/ASA_Pattern.svg)', backgroundSize: 'auto 100%', opacity: 0.6 }} />
      <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 5, backgroundImage: 'url(assets/patterns/ASA_Pattern.svg)', backgroundSize: 'auto 100%', opacity: 0.6 }} />
      <div style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', fontFamily: 'var(--ff-display)', fontStyle: 'italic', fontSize: 9, color: 'var(--asa-navy)' }}>“yes”</div>
    </>
  ) : (
    <>
      <div style={{ position: 'absolute', inset: 0, background: 'var(--asa-navy)' }} />
      <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 22, background: 'linear-gradient(135deg, #6c5240, #2a3a4d)' }} />
      <div style={{ position: 'absolute', left: 28, top: 13, fontFamily: 'var(--ff-display)', fontStyle: 'italic', fontSize: 13, color: 'var(--asa-cream)' }}>“</div>
    </>
  );
  return (
    <div className="varcard" data-on={on} onClick={onClick}>
      <div className="varcard__thumb">{thumb}</div>
      <div>
        <div className="varcard__name">{v.name}</div>
        <div className="varcard__sub">{v.sub}</div>
      </div>
    </div>
  );
}

const CATEGORY_LIST = [
  'shutters',
  'motorized_shades',
  'new_shades',
  'woven_wood',
  'drapery',
  'exterior_patio_shades',
  'motorized_patio_shades',
];

// Legacy drag-reorder + eye-toggle showroom editor. No longer wired into
// the inspector — the back of every card now shows all four showrooms in
// 2×2 with the matched-return-address slot first. Kept around in case we
// ever resurrect manual ordering for a non-mailer use case; safe to delete
// outright if nothing pulls it in by mid-2026.
// eslint-disable-next-line no-unused-vars
function ShowroomReorderer({ ids, hidden = [], showrooms, onReorder, onToggleVisible, visibleCap = Infinity }) {
  const [dragId, setDragId] = useState(null);
  const [overId, setOverId] = useState(null);
  const hiddenSet = new Set(hidden);
  // Order-aware visible-position tracker for badge numbering ("01", "02", ...)
  let nextVisibleIdx = 0;

  const onDragStart = (id) => (e) => {
    setDragId(id);
    e.dataTransfer.effectAllowed = 'move';
    try { e.dataTransfer.setData('text/plain', id); } catch (_) {}
  };
  const onDragOver = (id) => (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    if (id !== overId) setOverId(id);
  };
  const onDragLeave = () => setOverId(null);
  const onDrop = (targetId) => (e) => {
    e.preventDefault();
    if (!dragId || dragId === targetId) {
      setDragId(null); setOverId(null); return;
    }
    const next = ids.filter(x => x !== dragId);
    const targetIdx = next.indexOf(targetId);
    next.splice(targetIdx, 0, dragId);
    onReorder(next);
    setDragId(null); setOverId(null);
  };
  const onDragEnd = () => { setDragId(null); setOverId(null); };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
      {ids.map((id) => {
        const sr = showrooms[id];
        if (!sr) return null;
        const isHidden = hiddenSet.has(id);
        const isVisible = !isHidden && nextVisibleIdx < visibleCap;
        const overflowsCap = !isHidden && nextVisibleIdx >= visibleCap;
        const visibleIdx = isVisible ? nextVisibleIdx : null;
        if (isVisible) nextVisibleIdx += 1;
        const isDragging = dragId === id;
        const isOver = overId === id && dragId && dragId !== id;
        // Determine background tone — visible rows get the warm card chip; hidden/overflow are muted
        const bg = isOver
          ? 'rgba(34,80,140,0.06)'
          : (isVisible ? 'var(--asa-stone-100, #f3efe7)' : 'transparent');
        const borderStyle = isOver ? 'dashed' : (isVisible ? 'solid' : 'dashed');
        const borderColor = isOver ? 'var(--asa-navy)' : 'var(--asa-stone-200)';
        const rowOpacity = isDragging ? 0.4 : (isVisible ? 1 : 0.55);
        return (
          <div
            key={id}
            draggable
            onDragStart={onDragStart(id)}
            onDragOver={onDragOver(id)}
            onDragLeave={onDragLeave}
            onDrop={onDrop(id)}
            onDragEnd={onDragEnd}
            style={{
              display: 'flex', alignItems: 'center', gap: 10,
              padding: '8px 10px',
              background: bg,
              border: `1px ${borderStyle} ${borderColor}`,
              borderRadius: 3,
              opacity: rowOpacity,
              cursor: 'grab',
              userSelect: 'none',
              transition: 'background 120ms ease, border-color 120ms ease, opacity 120ms ease',
            }}
            title={
              isHidden
                ? 'Hidden — click the eye to show'
                : (overflowsCap ? `Beyond the ${visibleCap}-showroom cap — drag higher to show` : 'Drag to reorder')
            }
          >
            {/* drag handle */}
            <span aria-hidden="true" style={{
              fontFamily: 'var(--ff-sans)', fontWeight: 600, fontSize: 14,
              color: 'var(--asa-stone-500)', lineHeight: 1, letterSpacing: '-0.05em',
              cursor: 'grab',
            }}>⋮⋮</span>
            {/* order index — only for the active visible rows */}
            <span style={{
              fontFamily: 'var(--ff-sans)', fontSize: 10, fontWeight: 600,
              letterSpacing: '0.18em',
              color: isVisible ? 'var(--asa-navy)' : 'var(--asa-stone-400, #b6ad99)',
              minWidth: 16,
            }}>{isVisible ? String(visibleIdx + 1).padStart(2, '0') : '—'}</span>
            <span style={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 }}>
              <span style={{
                fontFamily: 'var(--ff-sans)', fontSize: 12, fontWeight: 500,
                color: isVisible ? 'var(--asa-navy)' : 'var(--asa-stone-500)',
                textDecoration: isHidden ? 'line-through' : 'none',
                textDecorationColor: 'rgba(0,0,0,0.25)',
              }}>{sr.name}</span>
              <span style={{ fontSize: 10, color: 'var(--asa-stone-500)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{sr.addr}</span>
            </span>
            {/* eye toggle */}
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onToggleVisible && onToggleVisible(id, isHidden); }}
              onMouseDown={(e) => e.stopPropagation()}
              draggable={false}
              aria-label={isHidden ? 'Show on card' : 'Hide from card'}
              title={isHidden ? 'Show on card' : 'Hide from card'}
              style={{
                width: 28, height: 28, borderRadius: 3,
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                background: isHidden ? 'transparent' : 'rgba(34,80,140,0.08)',
                border: `1px solid ${isHidden ? 'var(--asa-stone-200)' : 'rgba(34,80,140,0.25)'}`,
                color: isHidden ? 'var(--asa-stone-500)' : 'var(--asa-navy)',
                cursor: 'pointer', flex: '0 0 auto',
              }}
            >
              {isHidden
                ? (
                  /* eye-off */
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="M3 3l18 18" />
                    <path d="M10.6 6.1a10.7 10.7 0 0 1 1.4-.1c5.5 0 9.5 6 9.5 6a16 16 0 0 1-2.6 3.4" />
                    <path d="M6.6 6.6A16 16 0 0 0 2.5 12s4 6 9.5 6c1.6 0 3-.4 4.3-1" />
                    <path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
                  </svg>
                )
                : (
                  /* eye */
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="M2.5 12s4-6 9.5-6 9.5 6 9.5 6-4 6-9.5 6S2.5 12 2.5 12z" />
                    <circle cx="12" cy="12" r="2.6" />
                  </svg>
                )
              }
            </button>
          </div>
        );
      })}
    </div>
  );
}

function PhotoUploader({ label, src, onChange, hint }) {
  const inputRef = useRef(null);
  const onPick = (file) => {
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => onChange(reader.result);
    reader.readAsDataURL(file);
  };
  return (
    <div className="field" style={{ marginBottom: 14 }}>
      <label className="field__label">{label}</label>
      <div
        style={{
          display: 'grid', gridTemplateColumns: '64px 1fr', gap: 10,
          alignItems: 'center',
          border: '1px solid var(--asa-stone-300)', borderRadius: 4,
          padding: 8, background: 'var(--asa-white)',
        }}
        onDragOver={e => { e.preventDefault(); }}
        onDrop={e => { e.preventDefault(); onPick(e.dataTransfer.files?.[0]); }}
      >
        <div style={{
          width: 64, height: 64, background: 'var(--asa-stone-200)',
          backgroundImage: src ? `url(${src})` : 'none',
          backgroundSize: 'cover', backgroundPosition: 'center',
          borderRadius: 2,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          color: 'var(--asa-stone-500)', fontSize: 9, letterSpacing: '0.18em',
          textTransform: 'uppercase',
        }}>
          {!src && 'Empty'}
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
          <div style={{ display: 'flex', gap: 6 }}>
            <button
              type="button"
              className="btn btn--ghost"
              style={{ fontSize: 11, padding: '4px 10px' }}
              onClick={() => inputRef.current?.click()}
            >
              {src ? 'Replace' : 'Upload'}
            </button>
            {src && (
              <button
                type="button"
                className="btn btn--ghost"
                style={{ fontSize: 11, padding: '4px 10px' }}
                onClick={() => onChange('')}
              >
                Clear
              </button>
            )}
          </div>
          <div style={{ fontSize: 10, color: 'var(--asa-stone-500)' }}>
            Drag &amp; drop or click Upload
          </div>
        </div>
        <input
          ref={inputRef}
          type="file"
          accept="image/*"
          style={{ display: 'none' }}
          onChange={e => onPick(e.target.files?.[0])}
        />
      </div>
      {hint && <div className="hint" style={{ marginTop: 6 }}>{hint}</div>}
    </div>
  );
}

function JustSoldInspector({ state, set }) {
  // Stripped-down sidebar for the Just-Sold variant. Everything that needs
  // to go on the card is auto-filled from the visualizer hand-off, so the
  // only knobs we expose are showroom ordering + the print-marks toggle.
  const photoCount = [state.heroSrc, state.renderSrc1, state.renderSrc2].filter(Boolean).length;
  return (
    <div className="sidebar">
      <Section num="01" title="Imported from listing">
        <div className="hint" style={{ marginBottom: 10 }}>
          Auto-filled from the window-counter visualizer. No edits needed —
          just hit <em>Continue to Recipients</em> when you're happy with the layout.
        </div>
        <div style={{
          padding: '10px 12px',
          background: 'rgba(243,237,228,0.06)',
          border: '1px solid rgba(243,237,228,0.14)',
          borderRadius: 6,
          fontSize: 12, lineHeight: 1.5, color: 'rgba(243,237,228,0.78)',
        }}>
          <div style={{ marginBottom: 6 }}>
            <span style={{ color: 'var(--asa-cream)' }}>Address:</span> {state.street || '—'}
          </div>
          <div style={{ marginBottom: 6 }}>
            <span style={{ color: 'var(--asa-cream)' }}>Photos:</span> {photoCount} loaded (1 hero + 2 alternates)
          </div>
          <div>
            <span style={{ color: 'var(--asa-cream)' }}>Audience:</span> the new homeowner (4-week drip)
          </div>
        </div>
      </Section>

      <Section num="02" title="Print">
        <div
          className="toggle"
          data-on={state.printMarks}
          onClick={() => set({ printMarks: !state.printMarks })}
        >
          <span className="toggle__label">Show print marks</span>
          <span className="switch" />
        </div>
        <div style={{
          marginTop: 12,
          fontSize: 10, lineHeight: 1.55, color: 'rgba(243,237,228,0.55)',
          fontFamily: 'var(--ff-text)', fontStyle: 'italic',
        }}>
          6 × 11" EDDM jumbo · 0.125" bleed · 0.25" safe area · CMYK 300 dpi.
        </div>
      </Section>
    </div>
  );
}

function Inspector({ state, set }) {
  // Just-Sold variant: imported wholesale from the window-counter visualizer.
  // Hide the input-heavy inspector — only thing the user might tweak is the
  // showroom list (which shows on the back). Everything else (address, photos,
  // copy, headline) is auto-filled from the import payload.
  if (state.variant === 'justSold') {
    return <JustSoldInspector state={state} set={set} />;
  }

  const family = state.lastName
    ? `The ${state.lastName}${/(s|x|z|ch|sh)$/i.test(state.lastName) ? 'es' : 's'}`
    : 'The Thorntons';
  return (
    <div className="sidebar">
      <Section num="01" title="Photos & Product">
        <div className="hint" style={{ marginBottom: 10 }}>
          Upload the install photo first — it drives the whole card. Use a real install shot, not a stock image.
        </div>
        <PhotoUploader
          label="Front photo"
          src={state.heroSrc}
          onChange={src => set({ heroSrc: src })}
          hint="Vanity shot for the front of the card."
        />
        <PhotoUploader
          label="Back photo (optional)"
          src={state.backPhotoSrc}
          onChange={src => set({ backPhotoSrc: src })}
          hint="Real photo of the install on the back. Leave empty to reuse the front photo."
        />
        <div className="row" style={{ marginTop: 12 }}>
          <div className="field">
            <label className="field__label">Product Type <span style={{ color: 'var(--asa-rust, #b35a3b)', fontWeight: 600 }}>*</span></label>
            <select
              className="select"
              value={state.productType || POSTCARD_CATEGORY_TYPE_OF[state.category] || ''}
              aria-invalid={!(state.productType || POSTCARD_CATEGORY_TYPE_OF[state.category])}
              style={!(state.productType || POSTCARD_CATEGORY_TYPE_OF[state.category]) ? { borderColor: 'var(--asa-rust, #b35a3b)' } : undefined}
              onChange={e => {
                /* Setting/changing the type clears the specific product —
                   the user must pick one explicitly; we never auto-default. */
                set({ productType: e.target.value, category: '' });
              }}
            >
              <option value="">— Not selected —</option>
              {POSTCARD_CATEGORY_TYPE_LIST.map(t => (
                <option key={t} value={t}>{POSTCARD_CATEGORY_TYPES[t].label}</option>
              ))}
            </select>
          </div>
          <div className="field">
            <label className="field__label">Product <span style={{ color: 'var(--asa-rust, #b35a3b)', fontWeight: 600 }}>*</span></label>
            <select
              className="select"
              value={state.category}
              onChange={e => {
                const c = e.target.value;
                /* Picking a product also resolves the type — keep them in sync. */
                set({ category: c, productType: c ? POSTCARD_CATEGORY_TYPE_OF[c] : state.productType });
              }}
              disabled={!(state.productType || POSTCARD_CATEGORY_TYPE_OF[state.category])}
              aria-invalid={!state.category}
              style={!state.category ? { borderColor: 'var(--asa-rust, #b35a3b)' } : undefined}
            >
              <option value="">— Not selected —</option>
              {(POSTCARD_CATEGORY_TYPES[state.productType || POSTCARD_CATEGORY_TYPE_OF[state.category]]?.subs || []).map(c => (
                <option key={c} value={c}>{POSTCARD_CATEGORY_LABEL[c]}</option>
              ))}
            </select>
          </div>
        </div>
      </Section>

      <Section num="02" title="Neighbor">
        {/* Client last name field removed — privacy: the family name no
            longer appears on the printed card. The state field remains
            (so existing imports don't break) but isn't surfaced here. */}
        <Field label={<span>Street <span style={{ color: 'var(--asa-rust, #b35a3b)', fontWeight: 600 }}>*</span></span>}>
          <input
            className="input"
            value={state.street}
            onChange={e => set({ street: toTitleCase(e.target.value) })}
            placeholder="1234 Oak Street"
            required
            aria-required="true"
            aria-invalid={!state.street.trim()}
            style={!state.street.trim() ? { borderColor: 'var(--asa-rust, #b35a3b)' } : undefined}
          />
          <div className="hint">Required. Paste the full address (1234 Oak St) — only the street name renders on the printed card. House numbers are stripped for privacy.</div>
          {!state.street.trim() && (
            <div className="hint" style={{ color: 'var(--asa-rust, #b35a3b)', marginTop: 4 }}>
              A street address is required.
            </div>
          )}
        </Field>
        <Field label={
          <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
            <span>Locale <span style={{ color: 'var(--asa-rust, #b35a3b)', fontWeight: 600 }}>*</span></span>
            <span className="seg seg--mini" role="tablist" aria-label="Locale mode" style={{ display: 'inline-flex' }}>
              <button
                type="button"
                role="tab"
                aria-pressed={state.localeMode !== 'city'}
                onClick={() => set({ localeMode: 'neighborhood', city: '' })}
              >Neighborhood</button>
              <button
                type="button"
                role="tab"
                aria-pressed={state.localeMode === 'city'}
                onClick={() => set({ localeMode: 'city' })}
              >City</button>
            </span>
          </span>
        }>
          <div className="hint" style={{ marginTop: -4, marginBottom: 6 }}>
            {state.localeMode === 'city'
              ? 'Renders as “Charlotte, NC” on the card. Use this when no specific neighborhood applies.'
              : 'Renders as just “Dilworth” on the card. Preferred — feels more local.'}
          </div>
          {state.localeMode === 'city' ? (
            <div className="row">
              <Field label="City">
                <input
                  className="input"
                  value={state.neighborhood}
                  onChange={e => set({ neighborhood: toTitleCase(e.target.value) })}
                  placeholder="Charlotte"
                  required
                  aria-required="true"
                  aria-invalid={!state.neighborhood.trim()}
                  style={!state.neighborhood.trim() ? { borderColor: 'var(--asa-rust, #b35a3b)' } : undefined}
                />
                {!state.neighborhood.trim() && (
                  <div className="hint" style={{ color: 'var(--asa-rust, #b35a3b)', marginTop: 4 }}>
                    A city is required.
                  </div>
                )}
              </Field>
              <Field label="State">
                <input
                  className="input"
                  value={state.city}
                  maxLength={2}
                  onChange={e => {
                    /* Force uppercase, letters only, max 2 chars; only accept NC or SC. */
                    const raw = (e.target.value || '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
                    set({ city: raw });
                  }}
                  onBlur={() => {
                    /* On blur, clear anything other than NC or SC — but allow empty. */
                    const v = (state.city || '').toUpperCase();
                    if (v && v !== 'NC' && v !== 'SC') set({ city: '' });
                  }}
                  placeholder="NC"
                  required
                  aria-required="true"
                  aria-invalid={state.city !== 'NC' && state.city !== 'SC'}
                  style={{
                    textTransform: 'uppercase',
                    letterSpacing: '0.08em',
                    fontWeight: 600,
                    ...(state.city !== 'NC' && state.city !== 'SC'
                      ? { borderColor: 'var(--asa-rust, #b35a3b)' }
                      : {}),
                  }}
                />
                {(state.city !== 'NC' && state.city !== 'SC') && (
                  <div className="hint" style={{ color: 'var(--asa-rust, #b35a3b)', marginTop: 4 }}>
                    NC or SC.
                  </div>
                )}
              </Field>
            </div>
          ) : (
            <>
              <input
                className="input"
                value={state.neighborhood}
                onChange={e => set({ neighborhood: toTitleCase(e.target.value) })}
                placeholder="Dilworth / Myers Park / Firethorne"
                required
                aria-required="true"
                aria-invalid={!state.neighborhood.trim()}
                style={!state.neighborhood.trim() ? { borderColor: 'var(--asa-rust, #b35a3b)' } : undefined}
              />
              {!state.neighborhood.trim() && (
                <div className="hint" style={{ color: 'var(--asa-rust, #b35a3b)', marginTop: 4 }}>
                  A neighborhood is required.
                </div>
              )}
            </>
          )}
        </Field>
      </Section>



      <Section num="03" title="Offer">
        <>
            <Field label="Default offer">
              <div style={{
                fontFamily: 'var(--ff-text)', fontSize: 13, lineHeight: 1.4,
                color: 'var(--asa-stone-700)',
                background: 'var(--asa-stone-100, #f3efe7)',
                border: '1px solid var(--asa-stone-200)',
                padding: '8px 10px', borderRadius: 3,
              }}>
                <span style={{ letterSpacing: '0.18em', fontSize: 10, color: 'var(--asa-stone-500)', textTransform: 'uppercase' }}>Complimentary</span>{' '}
                <span style={{ fontStyle: 'italic' }}>{POSTCARD_DEFAULT_OFFER_TEXT}</span>
              </div>
            </Field>
            <div
              className="toggle"
              data-on={state.promoOn}
              onClick={() => set({ promoOn: !state.promoOn })}
              style={{ marginBottom: 10 }}
            >
              <span className="toggle__label">Promotional offer</span>
              <span className="switch" />
            </div>
            {state.promoOn && (
              <Field label="Promo text (replaces default)">
                <textarea
                  className="textarea"
                  rows="2"
                  value={state.promoText}
                  onChange={e => set({ promoText: e.target.value })}
                  placeholder="First Motor Free when buying 4 or more motorized shades"
                />
                <div className="hint">Card will read “Featuring &mdash; {state.promoText || 'your promo text'}”.</div>
              </Field>
            )}
        </>
      </Section>

      <Section num="04" title="Contact">
        <div className="row">
          <Field label="Phone">
            <input className="input" value={state.phone}
              onChange={e => set({ phone: e.target.value })} />
          </Field>
          <Field label="URL">
            <input className="input" value={state.url}
              onChange={e => set({ url: e.target.value })} />
          </Field>
        </div>
      </Section>

      <Section num="05" title="Print">
        <div
          className="toggle"
          data-on={state.printMarks}
          onClick={() => set({ printMarks: !state.printMarks })}
        >
          <span className="toggle__label">Show print marks</span>
          <span className="switch" />
        </div>
        <div style={{
          marginTop: 12,
          fontSize: 10, lineHeight: 1.55, color: 'rgba(243,237,228,0.55)',
          fontFamily: 'var(--ff-text)', fontStyle: 'italic',
        }}>
          6 × 11" EDDM jumbo · 0.125" bleed · 0.25" safe area · CMYK 300 dpi · 16pt cover, gloss UV both sides.
        </div>
      </Section>
    </div>
  );
}

/* =========== Stage with auto-fit scaling =========== */

function Stage({ state, set }) {
  const containerRef = useRef(null);
  const [scale, setScale] = useState(1);
  const W = POSTCARD_FULL.w;
  const H = POSTCARD_FULL.h;
  const expanded = expand(state);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const compute = () => {
      const cs = el.getBoundingClientRect();
      const padding = 60;
      const sw = (cs.width - padding) / W;
      const sh = (cs.height - padding) / H;
      const next = Math.min(sw, sh, 1.4);
      setScale(next > 0 ? next : 1);
    };
    compute();
    const ro = new ResizeObserver(compute);
    ro.observe(el);
    return () => ro.disconnect();
  }, [W, H]);

  return (
    <div className="stage" ref={containerRef}>
      <div className="stage__inner">
        <div
          className="flipper"
          data-side={state.side}
          style={{ width: W, height: H, transform: `scale(${scale})` }}
        >
          <div className="flipper__inner" style={{ width: W, height: H }}>
            <div className="flipper__face flipper__face--front pc-shadow">
              <PostcardFront s={expanded} set={set} />
              <PrintMarks on={state.printMarks} />
            </div>
            <div className="flipper__face flipper__face--back pc-shadow">
              <PostcardBack s={expanded} set={set} />
              <PrintMarks on={state.printMarks} />
            </div>
          </div>
        </div>
      </div>

      <div className="stage__hud">
        <span><b>{state.side === 'front' ? 'FRONT' : 'BACK'}</b> · 11 × 6 in EDDM Jumbo</span>
        <span>· <b>{POSTCARD_VARIANTS[state.variant].name.toUpperCase()}</b></span>
        <span>· {POSTCARD_CATEGORY_LABEL[state.category] || 'No product selected'}</span>
      </div>

      <div className="stage__zoom">
        <span className="pct">{Math.round(scale * 100)}%</span>
      </div>

      {/* Print-only render: both faces stacked as 11.25×6.25 in pages. Hidden on screen. */}
      <div className="print-pages" aria-hidden="true">
        <div className="print-page">
          <div className="print-card">
            <PostcardFront s={expanded} set={set} />
          </div>
        </div>
        <div className="print-page">
          <div className="print-card">
            <PostcardBack s={expanded} set={set} />
          </div>
        </div>
      </div>
    </div>
  );
}

/* =========== Export modal =========== */

function ExportModal({ state, onClose }) {
  const expanded = expand(state);
  const family = `The ${state.lastName}${/(s|x|z|ch|sh)$/i.test(state.lastName) ? 'es' : 's'}`;
  const placeLine = [state.neighborhood, state.city].filter(Boolean).join(', ');
  const payload = {
    job: 'asa.neighbor.postcard.eddm',
    submittedAt: new Date().toISOString(),
    spec: {
      product: 'postcard',
      size: { trim: '11x6 in', bleed: '0.125 in', safe: '0.25 in' },
      stock: '16pt cover, gloss UV 4/4',
      colorMode: 'CMYK',
      resolution: '300 dpi',
      fold: 'none',
    },
    targeting: {
      type: 'EDDM Retail',
      origin: { neighborhood: state.neighborhood, city: state.city, family },
      radius: '0.5 mi',
      estCarrierRoutes: 12,
      estHouseholds: 1248,
      excludePOBoxes: true,
    },
    creative: {
      variant: state.variant,
      category: state.category,
      categoryLabel: POSTCARD_CATEGORY_LABEL[state.category],
      headline: state.headline,
      subhead: state.subhead,
      quote: null,
      back: { headline: state.backHeadline, body: expanded.body },
      offer: { eyebrow: expanded.offerEyebrow, text: expanded.offerText },
      promo: null,
      showrooms: POSTCARD_ALL_SHOWROOMS,
      contact: { phone: state.phone, url: state.url },
    },
    tracking: { dropCode: state.dropCode, matchback: 'modern-io' },
  };
  const json = JSON.stringify(payload, null, 2);

  return (
    <div className="modal-scrim" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <div className="modal__head">
          <div className="modal__eyebrow">Send to Printer · Confirmation</div>
          <h2 className="modal__title">A neighbor postcard, {expanded.dropCode ? 'queued for ' + expanded.dropCode + '.' : 'queued for delivery.'}</h2>
        </div>
        <div className="modal__body">
          <dl className="spec-grid">
            <div>
              <dt>Format</dt>
              <dd><b>6 × 11 in</b> · EDDM Jumbo Landscape</dd>
            </div>
            <div>
              <dt>Stock</dt>
              <dd><b>16pt</b> cover · gloss UV 4/4</dd>
            </div>
            <div>
              <dt>Bleed / Safe</dt>
              <dd>0.125" bleed · 0.25" safe area</dd>
            </div>
            <div>
              <dt>Color Mode</dt>
              <dd>CMYK · 300 dpi</dd>
            </div>
            <div>
              <dt>Targeting</dt>
              <dd><b>EDDM Retail</b> · {placeLine}<br />~1,248 households · 12 carrier routes</dd>
            </div>
            <div>
              <dt>Featured Neighbor</dt>
              <dd>{family}<br />{placeLine}</dd>
            </div>
          </dl>
          <div style={{ height: 16 }} />
          <div className="modal__eyebrow">API Payload preview</div>
          <pre className="payload">{json}</pre>
        </div>
        <div className="modal__foot">
          <span className="pre">Mock — no order will be submitted</span>
          <div className="actions">
            <button className="btn-ghost-light" onClick={onClose}>Cancel</button>
            <button className="btn-primary-light" onClick={onClose}>Confirm &amp; Submit</button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* =========== Root =========== */

// Wizard mode: when this designer is loaded inside an iframe with ?wizard=1,
// the "Send to Printer" button becomes "Continue to Recipients →" and clicking
// it captures the rendered front+back as PNG (via html2canvas) and postMessages
// the state + image data URLs to the parent window. The parent (map-viewer)
// uses those for the recipient-selection and review screens.
const WIZARD_MODE = new URLSearchParams(location.search).get('wizard') === '1';

async function captureFaceAsPng(selector) {
  const el = document.querySelector(selector);
  if (!el) throw new Error(`Postcard face not found: ${selector}`);
  // Render at the print-page size already in the DOM; html2canvas defaults to 1×.
  // Bump scale to 2× for crisper output without doubling memory excessively.
  const canvas = await window.html2canvas(el, {
    backgroundColor: null,
    scale: 2,
    useCORS: true,
    logging: false,
  });
  return canvas.toDataURL('image/png');
}

async function postWizardContinue(state) {
  // The two .print-card elements live in .print-pages and render at the
  // print-ready 11.25×6.25 in size. They're hidden from view but always present.
  const cards = document.querySelectorAll('.print-pages .print-card');
  if (cards.length < 2) {
    window.parent.postMessage({ type: 'asa-postcard-error', error: 'Print cards not found' }, '*');
    return;
  }
  try {
    // Force the print-pages container temporarily visible (off-screen) so
    // html2canvas captures the rendered output. Some hidden setups produce
    // a blank canvas otherwise.
    const printPages = document.querySelector('.print-pages');
    const prev = printPages?.style.cssText || '';
    if (printPages) {
      printPages.style.cssText = `${prev}; position:fixed; left:-99999px; top:0; display:block; visibility:visible;`;
    }
    // Skip postal indicia + tracking code on the exported PNG. They show in the
    // on-screen preview as a visual reference, but LOB applies the real indicia
    // and per-recipient address block at print time, so they must NOT be baked
    // into the artwork we hand off.
    const skipPrintHide = (el) => el.classList && el.classList.contains('print-hide');
    // ── Cached preview only ──────────────────────────────────────────
    // JPEG @ 95%, 2× scale. Used for in-app preview between steps; small
    // enough to fit in localStorage's 5MB quota even with photo-heavy
    // designs. NOT the print-quality artwork — that is re-rendered fresh
    // at 4× PNG via the asa-postcard-render-print message handler below
    // right before LOB upload.
    // Preload all images first so the SVG logo doesn't render blank.
    await ensureImagesLoaded(printPages);
    const opts = { backgroundColor: '#fff', scale: 2, useCORS: true, allowTaint: true, logging: false, ignoreElements: skipPrintHide, imageTimeout: 15000 };
    const frontPng = await window.html2canvas(cards[0], opts).then(c => c.toDataURL('image/jpeg', 0.95));
    const backPng  = await window.html2canvas(cards[1], opts).then(c => c.toDataURL('image/jpeg', 0.95));
    if (printPages) printPages.style.cssText = prev;
    window.parent.postMessage({
      type: 'asa-postcard-continue',
      state,
      frontPng,
      backPng,
    }, '*');
  } catch (err) {
    window.parent.postMessage({ type: 'asa-postcard-error', error: err.message }, '*');
  }
}

function App() {
  const [state, setState] = useState(DEFAULT_STATE);
  const [exportOpen, setExportOpen] = useState(false);
  const [toast, setToast] = useState(null);
  const [capturing, setCapturing] = useState(false);

  const set = useCallback((patch) => setState(s => ({ ...s, ...patch })), []);

  // In wizard mode, listen for prefill from the parent so users can step back.
  useEffect(() => {
    if (!WIZARD_MODE) return;
    const onMsg = async (ev) => {
      const data = ev.data || {};
      if (data.type === 'asa-postcard-prefill' && data.state) {
        setState((s) => ({ ...s, ...data.state }));
        // Wait for React commit + browser to fully decode the new
        // photo data URLs before acking. The parent uses this ack
        // to time its html2canvas capture; without the decode wait,
        // version-B's capture would grab version-A's still-decoded
        // bitmaps on the second multi-week-drip render. Sequence:
        //   1. setState queues React update
        //   2. RAF #1 — React commits, DOM updated, img.src swapped
        //   3. RAF #2 — paint cycle, browser starts decoding the new src
        //   4. img.decode() on every img — waits for bitmap ready
        //   5. ack → parent triggers print render with fresh photos
        requestAnimationFrame(() => {
          requestAnimationFrame(async () => {
            try {
              const printPages = document.querySelector('.print-pages');
              if (printPages) {
                const imgs = [...printPages.querySelectorAll('img')].filter(
                  el => el.src && el.src !== 'about:blank'
                );
                await Promise.all(imgs.map(img =>
                  Promise.race([
                    img.decode().catch(() => {}),
                    new Promise((r) => setTimeout(r, 6000)),
                  ])
                ));
              }
              window.parent?.postMessage({
                type: 'asa-postcard-prefill-applied',
                fields: Object.keys(data.state || {}),
              }, '*');
            } catch (_) {
              // Ack anyway — better to let the parent proceed with a
              // best-effort capture than to stall on a decode error.
              try {
                window.parent?.postMessage({
                  type: 'asa-postcard-prefill-applied',
                  fields: Object.keys(data.state || {}),
                }, '*');
              } catch (_) {}
            }
          });
        });
        return;
      }
      // Parent (mapper/wizard step 3) requests print-quality artwork right
      // before LOB submit. Render at 4× scale as PNG (lossless) — only happens
      // once per campaign, so the slow render is fine.
      if (data.type === 'asa-postcard-render-print') {
        try {
          // Reuse the same hidden .print-pages container the standalone PDF
          // path uses; that ensures the cards are at full canvas size rather
          // than the editor's display-scaled size.
          const printPages = document.querySelector('.print-pages');
          const cards = printPages?.querySelectorAll('.print-card') || [];
          if (cards.length < 2) throw new Error('Print pages not ready — front+back containers missing');
          const prev = printPages.style.cssText || '';
          printPages.style.cssText = `${prev}; position:fixed; left:-99999px; top:0; display:block; visibility:visible;`;
          const skipPrintHide = (el) => el.classList && el.classList.contains('print-hide');
          // Preload all images (logo SVG, photos) so they don't render blank.
          await ensureImagesLoaded(printPages);
          // Diagnostic: log the natural dimensions of every photo
          // about to be rasterized. If these are e.g. 1024×1024 we
          // know Gemini is the bottleneck (no real upscaler in line);
          // if they're 3072×3072 the source data is fine and any
          // softness is downstream (html2canvas / JPEG encode / LOB).
          [...printPages.querySelectorAll('svg image, img')].forEach((el, i) => {
            const tag = el.tagName.toLowerCase();
            if (tag === 'image') {
              const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '';
              const probe = new Image(); probe.src = href;
              console.log(`[print] svg<image>#${i}: natural ${probe.naturalWidth}×${probe.naturalHeight} · href=${href.slice(0,80)}…`);
            } else {
              console.log(`[print] img#${i}: natural ${el.naturalWidth}×${el.naturalHeight} · src=${(el.src||'').slice(0,80)}…`);
            }
          });
          // Scale 5× → 4050×2250 for an 810×450 canvas. LOB requires
          // a minimum of 3375×1875 (300 DPI × 11.25" × 6.25" trim+bleed).
          // 4× was 3240×1800 — just under, every postcard rejected with 422.
          // Scale 4.2 clears LOB's minimum (3375×1875 for 6×11): 810×4.2=3402,
          // 450×4.2=1890. Was 5× before but rendering 9 MP per face was
          // hitting the parent's 45 s timeout on heavy postcards. 4.2× cuts
          // pixels by ~30% so the render finishes well inside the budget.
          const opts = { backgroundColor: '#fff', scale: 4.2, useCORS: true, allowTaint: true, logging: false, ignoreElements: skipPrintHide, imageTimeout: 30000 };
          // JPEG @ 97% — cuts payload ~5–10× vs PNG so a 4-week drip with
          // 4 photos fits inside the browser's fetch body limit. Bumped
          // from 0.94 to 0.97 because back-of-card thumbnails were showing
          // visible JPEG mosquito noise around shutter louvers / drape
          // folds. The size hit is small (~15%) but the print-quality win
          // is significant.
          const frontCanvas = await window.html2canvas(cards[0], opts);
          const backCanvas  = await window.html2canvas(cards[1], opts);
          console.log(`[print] front canvas: ${frontCanvas.width}×${frontCanvas.height}`);
          console.log(`[print] back canvas:  ${backCanvas.width}×${backCanvas.height}`);
          const frontPng = frontCanvas.toDataURL('image/jpeg', 0.97);
          const backPng  = backCanvas.toDataURL('image/jpeg', 0.97);
          if (!frontPng || !frontPng.startsWith('data:image/') || !backPng || !backPng.startsWith('data:image/')) {
            throw new Error('html2canvas produced an empty data URL');
          }
          printPages.style.cssText = prev;
          window.parent.postMessage({
            type: 'asa-postcard-print-render-complete',
            frontPng,
            backPng,
          }, '*');
        } catch (err) {
          window.parent.postMessage({
            type: 'asa-postcard-print-render-error',
            error: err.message || String(err),
          }, '*');
        }
      }
    };
    window.addEventListener('message', onMsg);
    // Tell parent we're ready (parent can now postMessage prefill if it has one).
    window.parent?.postMessage({ type: 'asa-postcard-ready' }, '*');
    return () => window.removeEventListener('message', onMsg);
  }, []);

  // keyboard: F flips, P toggles print marks, S opens send-to-printer
  useEffect(() => {
    const onKey = (e) => {
      if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
      if (e.key === 'f' || e.key === 'F') set({ side: state.side === 'front' ? 'back' : 'front' });
      if (e.key === 'p' || e.key === 'P') set({ printMarks: !state.printMarks });
      if (e.key === 's' || e.key === 'S') setExportOpen(true);
    };
    const onToast = (e) => { setToast(e.detail || 'Heads up'); setTimeout(() => setToast(null), 3000); };
    window.addEventListener('asa-toast', onToast);
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('keydown', onKey);
      window.removeEventListener('asa-toast', onToast);
    };
  }, [state.side, state.printMarks, set]);

  // auto-clear toast
  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(null), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  return (
    <div className="app" data-screen-label="01 Postcard Designer">
      <TopBar
        state={state}
        set={set}
        onExport={async () => {
          if (WIZARD_MODE) {
            if (capturing) return;
            setCapturing(true);
            try { await postWizardContinue(state); }
            finally { setCapturing(false); }
          } else {
            setExportOpen(true);
          }
        }}
        wizardMode={WIZARD_MODE}
        capturing={capturing}
      />
      <Inspector state={state} set={set} />
      <Stage state={state} set={set} />
      {exportOpen && <ExportModal state={state} onClose={() => setExportOpen(false)} />}
      {toast && <div className="toast">{toast}</div>}
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
