// Lookbook.jsx — editorial scroll of looks.
// Layout (imgCols 1|2, imgSplit, imgGap, imgHeight) is shared across devices.
// Each photo's position/zoom is a FREE transform {x%, y%, s} stored PER DEVICE
// in imageMeta[i].desktop / .mobile, edited with a Figma-style transform box.
// The image file is shared. A clean-preview toggle hides all of this.

const LB_DEFAULT_H = Math.round(window.innerHeight * 0.78);
const CH_DEFAULT_H = Math.round(window.innerHeight * 0.62);  // default chapter band height
const COVER_TF = { x: 50, y: 50, s: 1 };   // cover model: focal x/y (0–100), zoom s (≥1)

// Resolve which device an actual viewport falls into (retailer side).
// ≤900 uses the mobile composition — 700–900 windows rendered the desktop
// canvas too small; the mobile book scales UP to fill them instead.
const deviceForVw = (vw) => vw <= 900 ? "mobile" : "desktop";
// "170" / "170cm" / "1.70cm" all display as MODEL IS 1.70cm.
const fmtHeight = (h) => {
  const m = String(h == null ? "" : h).match(/(\d+(?:[.,]\d+)?)/);
  if (!m) return h ? String(h) : "";
  let v = parseFloat(m[1].replace(",", "."));
  if (!isFinite(v) || v <= 0) return String(h);
  if (v >= 100) v = v / 100;
  return v.toFixed(2) + "cm";
};

// Right-click menus render position:fixed INSIDE the scaled canvas — a CSS
// transform makes the canvas their containing block, so cursor coords must be
// converted into canvas space (and divided by the zoom).
const canvasMenuPos = (e) => {
  const c = e.target && e.target.closest ? e.target.closest(".lb-lock-canvas") : null;
  if (!c) return { x: e.clientX, y: e.clientY };
  const r = c.getBoundingClientRect();
  const z = (r.width / c.offsetWidth) || 1;
  return { x: (e.clientX - r.left) / z, y: (e.clientY - r.top) / z };
};

// Pick a photo's cover transform for a device.
const tfPick = (m, device) => (m && m[device]) || COVER_TF;

// Height is shared across devices EXCEPT when a mobile-only override is set, so
// the editor can tune a shorter/taller band on phones without moving desktop.
const heightField = (device) => (device === "mobile" ? "imgHeightMobile" : "imgHeight");
const resolveHeight = (look, device, def) =>
  (device === "mobile" && look.imgHeightMobile != null) ? look.imgHeightMobile
    : (look.imgHeight != null ? look.imgHeight : def);

// Reference widths per device: a chapter band's height is stored "at this width"
// and rendered via aspect-ratio, so the band keeps a CONSTANT shape as the window
// resizes → the cover crop never shifts → overlaid title/text stick to the image.
const CH_REF_W = { desktop: 1440, mobile: 430 };

// Chapter free-placement defaults (percentages of the band, per device).
const CH_DEFAULTS = {
  desktop: { title: { x: 50, y: 40 }, text: { x: 50, y: 58, w: 44 } },
  mobile:  { title: { x: 50, y: 38 }, text: { x: 50, y: 60, w: 84 } },
};
const chPos = (look, device, which) => {
  const blk = look.blockMeta || {};
  const def = CH_DEFAULTS[device][which];
  const cur = (blk[device] && blk[device][which]) || {};
  return { ...def, ...cur };
};
// Per-device sizing/alignment, independent per device. bm.titleSize (no device) is
// the legacy shared value, used only as a fallback for old chapters.
const CH_STYLE_DEFAULTS = {
  desktop: { titleSize: 56, textSize: 15, align: "center" },
  mobile:  { titleSize: 34, textSize: 13, align: "center" },
};
const chStyle = (look, device) => {
  const bm = look.blockMeta || {};
  const cur = bm[device] || {};
  const d = CH_STYLE_DEFAULTS[device];
  return {
    titleSize: cur.titleSize || bm.titleSize || d.titleSize,
    textSize: cur.textSize || d.textSize,
    align: cur.align || d.align,
  };
};

// ── Transform box (cover pan/zoom) ───────────────────────────────────────────
// The image always FILLS the frame (object-fit: cover), so the box is the frame
// itself. Drag the body to pan (object-position focal), drag a corner to zoom
// (scale ≥ 1 toward the focal point). Everything is stored as ratios, so the
// crop reproduces identically at any window/screen size — no gaps, no breakage.
function TransformBox({ tf, onChange, onCommit }) {
  const ref = React.useRef(null);
  const drag = React.useRef(null);
  // The sized frame is either a .lb-frame ancestor (look photos) or, for chapter
  // backgrounds, simply the box this transform box is a direct child of.
  const frameEl = () => ref.current && (ref.current.closest(".lb-frame") || ref.current.parentElement);
  const begin = (mode) => (e) => {
    e.preventDefault(); e.stopPropagation();
    const r = frameEl().getBoundingClientRect();
    const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
    drag.current = { mode, FW: r.width, FH: r.height, cx, cy, sx: e.clientX, sy: e.clientY,
      startDist: Math.hypot(e.clientX - cx, e.clientY - cy) || 1,
      tf: { x: tf.x == null ? 50 : tf.x, y: tf.y == null ? 50 : tf.y, s: tf.s == null ? 1 : tf.s } };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", end);
  };
  const move = (e) => {
    const d = drag.current; if (!d) return;
    if (d.mode === "pan") {
      // Drag image right → reveal more of its left → focal x decreases.
      const x = Math.max(0, Math.min(100, d.tf.x - ((e.clientX - d.sx) / d.FW) * 100));
      const y = Math.max(0, Math.min(100, d.tf.y - ((e.clientY - d.sy) / d.FH) * 100));
      d.last = { x, y, s: d.tf.s }; onChange(d.last);
    } else {
      const dist = Math.hypot(e.clientX - d.cx, e.clientY - d.cy);
      let s = Math.max(1, Math.min(4, d.tf.s * (dist / d.startDist)));
      // Shift zooms about the center (focal snaps to middle).
      const x = e.shiftKey ? 50 : d.tf.x, y = e.shiftKey ? 50 : d.tf.y;
      d.last = { x, y, s }; onChange(d.last);
    }
  };
  const end = () => {
    window.removeEventListener("pointermove", move);
    window.removeEventListener("pointerup", end);
    const d = drag.current; drag.current = null;
    if (d && d.last) onCommit(d.last);
  };
  const s = tf.s == null ? 1 : tf.s;
  return (
    <div ref={ref} className="lb-tbox" onPointerDown={begin("pan")} onClick={(e) => e.stopPropagation()}>
      <span className="lb-tbox-label">{Math.round(s * 100)}%</span>
      {["nw", "ne", "sw", "se"].map((c) => (
        <span key={c} className={"lb-tbox-h lb-tbox-" + c} onPointerDown={begin("zoom")} />
      ))}
    </div>
  );
}
window.SBTransformBox = TransformBox;   // reused by the PDP editor (detail crops)

function LookCard({ look, collection, onOpenSku, onHoverImage, onOpenImage, deviceOverride }) {
  const ec = React.useContext(window.EditCtx);
  const editing = ec && ec.edit;
  const showEdit = editing && !ec.preview;        // editor affordances visible?
  const gridRef = React.useRef(null);
  const [menu, setMenu] = React.useState(null);
  const replaceRef = React.useRef(null);
  const drag = React.useRef(null);
  const [vw, setVw] = React.useState(typeof window !== "undefined" ? window.innerWidth : 1280);
  React.useEffect(() => { const f = () => setVw(window.innerWidth); window.addEventListener("resize", f); return () => window.removeEventListener("resize", f); }, []);
  // Which device's transform applies: in edit, whatever the editor is tuning;
  // for retailers, derived from their viewport width (or forced by the mobile
  // FEED view, which shows the desktop composition scaled down).
  const device = editing ? (ec.device || "desktop") : (deviceOverride || deviceForVw(vw));

  const realImages = (look.images || []).filter(Boolean);
  const cols = look.imgCols != null ? look.imgCols : Math.min(2, Math.max(1, realImages.length || 1));

  // ── phone memory guard: campaign files decode to ~60MB each, and Safari's
  // lazy-loader fetches many screens ahead — enough to crash iOS. On retail
  // mobile only, a look keeps its <img> mounted ONLY while near the viewport
  // (placeholders keep the layout; frames have fixed heights, so no shift).
  const rootRef = React.useRef(null);
  // guard by the REAL viewport, not the composition device — the mobile FEED
  // view renders the desktop composition but still runs on a phone
  const winGuard = !editing && deviceForVw(vw) === "mobile";
  const [nearView, setNearView] = React.useState(!winGuard);
  React.useEffect(() => {
    if (!winGuard) { setNearView(true); return; }
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") { setNearView(true); return; }
    const io = new IntersectionObserver(([en]) => setNearView(en.isIntersecting),
      { rootMargin: "140% 0px" });
    io.observe(el);
    return () => io.disconnect();
  }, [winGuard]);
  // Composition is per-photo now (imageMeta[i].skuIds); used here only to give an
  // empty frame a placeholder colour from its first product.
  const frameSkus = (i) => (((look.imageMeta || [])[i] || {}).skuIds || []).map((id) => collection.find((p) => p.id === id)).filter(Boolean);
  const split = look.imgSplit != null ? look.imgSplit : 50;
  const gap = look.imgGap != null ? look.imgGap : 24;
  const height = resolveHeight(look, device, LB_DEFAULT_H);
  const meta = look.imageMeta || [];
  // label shows size + height only — the model's name stays data, never display
  const metaLabel = (m) => (m && (m.size || m.height))
    ? [m.size ? `WEARS ${m.size}` : "", m.height ? `MODEL IS ${fmtHeight(m.height)}` : ""].filter(Boolean).join("  ·  ") : null;
  const tfOf = (i) => tfPick(meta[i], device);

  const setLookImage = (idx, path) => {
    const arr = realImages.slice();
    while (arr.length <= idx) arr.push(null);
    arr[idx] = path;
    ec.saveLook(look.id, { images: arr.filter(Boolean) });
  };
  // Library picker — choose a TAGGED image from the media library: sets the
  // photo AND merges the image's tagged products into this frame's composition
  // (one save, one undo step).
  const [libOpen, setLibOpen] = React.useState(null);   // frame index or null
  const [libItems, setLibItems] = React.useState(null); // fetched on first open
  const [libQ, setLibQ] = React.useState("");
  const openLibraryAt = (i) => {
    setLibQ("");
    setLibOpen((cur) => (cur === i ? null : i));
    if (!libItems) window.sbAdmin.get("/api/admin/media").then(setLibItems).catch(() => setLibItems([]));
  };
  const openLibrary = (i) => (e) => { e.stopPropagation(); openLibraryAt(i); };
  // Search matches filename, MODEL, and the names/colours of the TAGGED products.
  const libMatches = (m, q) => {
    const term = q.trim().toLowerCase();
    if (!term) return true;
    let hay = (m.name || "") + " " + (m.model || "");
    for (const id of (m.skuIds || [])) {
      const p = collection.find((s) => s.id === id);
      if (p) hay += " " + p.name + " " + (p.colorway ? p.colorway.name : "");
    }
    return hay.toLowerCase().includes(term);
  };
  React.useEffect(() => {
    if (libOpen === null) return;
    const close = () => setLibOpen(null);
    const esc = (e) => { if (e.key === "Escape") close(); };
    window.addEventListener("click", close);
    window.addEventListener("keydown", esc);
    return () => { window.removeEventListener("click", close); window.removeEventListener("keydown", esc); };
  }, [libOpen]);
  const pickFromLibrary = (i, m) => {
    const arr = realImages.slice();
    while (arr.length <= i) arr.push(null);
    arr[i] = m.path;
    const metaArr = (look.imageMeta || []).map((o) => ({ ...(o || {}) }));
    while (metaArr.length <= i) metaArr.push({});
    const existing = metaArr[i].skuIds || [];
    metaArr[i] = {
      ...metaArr[i],
      skuIds: [...existing, ...(m.skuIds || []).filter((id) => !existing.includes(id))],
      model: m.model || "",
      modelHeight: m.modelHeight != null ? String(m.modelHeight) : "",
      // auto-fill the on-canvas height label from the model's height (kept
      // editable in the inspector — only fills when empty)
      height: metaArr[i].height || (m.modelHeight ? m.modelHeight + "cm" : ""),
    };
    ec.saveLook(look.id, { images: arr.filter(Boolean), imageMeta: metaArr });
    setLibOpen(null);
  };
  // Write a frame's transform for the CURRENT device only (other device untouched).
  // A pre-drag snapshot (captured on the first preview) is the undo baseline.
  const tfSnap = React.useRef(null);
  const setTransform = (i, tf, commit) => {
    const m = (look.imageMeta || []).map((o) => ({ ...o }));
    while (m.length <= i) m.push({ size: "", height: "" });
    m[i] = { ...m[i], [device]: tf };
    if (commit) { ec.saveLook(look.id, { imageMeta: m }, tfSnap.current ? { prev: tfSnap.current } : {}); tfSnap.current = null; }
    else { if (!tfSnap.current) tfSnap.current = look; ec.previewLook(look.id, { imageMeta: m }); }
  };

  // Layout handles (shared across devices) ----------------------------------
  const startDivider = (e) => {
    e.preventDefault(); e.stopPropagation();
    drag.current = { type: "split", rect: gridRef.current.getBoundingClientRect(), val: split, snap: look };
    window.addEventListener("pointermove", onDrag); window.addEventListener("pointerup", endDrag);
  };
  const startHeight = (e) => {
    e.preventDefault(); e.stopPropagation();
    const g = gridRef.current, r = g.getBoundingClientRect();
    const zoom = (r.width / g.offsetWidth) || 1;   // canvas scale — store UNSCALED px
    drag.current = { type: "height", startY: e.clientY, startH: r.height / zoom, zoom, snap: look };
    window.addEventListener("pointermove", onDrag); window.addEventListener("pointerup", endDrag);
  };
  const onDrag = (e) => {
    const d = drag.current; if (!d) return;
    if (d.type === "split") {
      const v = Math.round(Math.min(85, Math.max(15, ((e.clientX - d.rect.left) / d.rect.width) * 100)));
      d.val = v; ec.previewLook(look.id, { imgSplit: v });
    } else {
      const v = Math.round(Math.min(1600, Math.max(220, d.startH + (e.clientY - d.startY) / (d.zoom || 1))));
      d.val = v; ec.previewLook(look.id, { [heightField(device)]: v });
    }
  };
  const endDrag = () => {
    const d = drag.current; drag.current = null;
    window.removeEventListener("pointermove", onDrag); window.removeEventListener("pointerup", endDrag);
    if (d) ec.saveLook(look.id, d.type === "split" ? { imgSplit: d.val } : { [heightField(device)]: d.val }, { prev: d.snap });
  };

  // Context menu -------------------------------------------------------------
  // Right-clicking ON a photo also remembers which frame, so the menu can offer
  // "search image…" (the library picker) for that exact frame.
  const openMenu = (e) => {
    if (!showEdit) return;
    e.preventDefault(); e.stopPropagation();
    ec.selectLook(look.id);
    const fr = e.target.closest && e.target.closest(".lb-frame");
    setMenu({ ...canvasMenuPos(e), frame: fr ? Number(fr.dataset.frame) : null });
  };
  React.useEffect(() => {
    if (!menu) return;
    const close = () => setMenu(null);
    window.addEventListener("click", close);
    window.addEventListener("scroll", close, true);
    const esc = (e) => { if (e.key === "Escape") close(); };
    window.addEventListener("keydown", esc);
    return () => { window.removeEventListener("click", close); window.removeEventListener("scroll", close, true); window.removeEventListener("keydown", esc); };
  }, [menu]);
  const menuAct = (fn) => (e) => { e.stopPropagation(); setMenu(null); fn(); };

  const renderFrame = (i) => {
    const real = realImages[i];
    const fbBg = (frameSkus(i)[0] || {}).bg;   // placeholder colour for an empty frame
    const ml = metaLabel(meta[i]);
    const basis = cols === 2 ? (i === 0 ? split : 100 - split) : 100;
    const isReal = !!real;
    const tf = tfOf(i);
    const ix = tf.x == null ? 50 : tf.x, iy = tf.y == null ? 50 : tf.y, is = tf.s == null ? 1 : tf.s;
    return (
      <div className={"lb-frame" + (!showEdit && onOpenImage ? " lb-frame-click" : "")} key={i} data-look-id={look.id} data-frame={i}
        style={{ flex: `0 0 calc(${basis}% - ${cols === 2 ? gap / 2 : 0}px)` }}
        onMouseEnter={!showEdit && onHoverImage ? () => onHoverImage(look.id, i) : undefined}
        onMouseLeave={!showEdit && onHoverImage ? () => onHoverImage(null) : undefined}
        onClick={!showEdit && onOpenImage ? () => onOpenImage(look.id, i) : undefined}
        {...(showEdit ? window.dropProps(ec, (path) => setLookImage(i, path)) : {})}>
        <div className="lb-frame-clip">
          {isReal && nearView
            ? <img className="photo-img" src={real} alt="" draggable={false}
                loading="lazy" decoding="async"
                srcSet={window.sbSrcSet(real)}
                sizes={Math.min(200, Math.round(basis * is)) + "vw"}
                style={{ objectPosition: `${ix}% ${iy}%`, transform: `scale(${is})`, transformOrigin: `${ix}% ${iy}%` }} />
            : <div className="photo-fill" style={{ background: fbBg || "var(--bg-3)" }} />}
          {ml && <div className="lb-photo-meta">{ml}</div>}
        </div>
        {showEdit && isReal && (
          <TransformBox tf={tf}
            onChange={(ntf) => setTransform(i, ntf, false)}
            onCommit={(ntf) => setTransform(i, ntf, true)} />
        )}
        {showEdit && <window.EditableImage title={isReal ? "⤓ replace · drop" : "⤓ add photo · drop"} onPick={(path) => setLookImage(i, path)} />}
        {showEdit && (
          <button type="button" className="lb-lib-chip" title="pick a tagged image from the media library — its products are assigned automatically"
            onClick={openLibrary(i)}>▤ library</button>
        )}
        {showEdit && libOpen === i && (
          <div className="lb-lib-pop" onClick={(e) => e.stopPropagation()}>
            <div className="lb-lib-pop-head">
              <input className="lb-lib-search" autoFocus placeholder="search by product, model or file…"
                value={libQ} onChange={(e) => setLibQ(e.target.value)} />
              <button className="lb-lib-x" onClick={() => setLibOpen(null)}>×</button>
            </div>
            <div className="lb-lib-grid">
              {/* search-first: no wall of images — results appear once you type */}
              {!libQ.trim() ? (
                <div className="lb-lib-empty">type to search — product, model or filename.</div>
              ) : libItems === null ? (
                <div className="lb-lib-empty">loading library…</div>
              ) : (
                <React.Fragment>
                  {libItems.filter((m) => libMatches(m, libQ)).map((m) => (
                    <button key={m.id} className="lb-lib-tile" title={m.name + (m.model ? " · " + m.model : "")}
                      onClick={() => pickFromLibrary(i, m)}>
                      <img src={window.sbImg(m.path, 480)} alt="" loading="lazy" draggable={false} />
                      <span className={"lb-lib-badge" + (m.skuIds.length ? " has" : "")}>
                        {m.skuIds.length ? m.skuIds.length + " ●" : "untagged"}
                      </span>
                      {m.model && <span className="lb-lib-model">{m.model}</span>}
                    </button>
                  ))}
                  {libItems.filter((m) => libMatches(m, libQ)).length === 0 && (
                    <div className="lb-lib-empty">no match for “{libQ}”.</div>
                  )}
                </React.Fragment>
              )}
            </div>
          </div>
        )}
      </div>
    );
  };

  return (
    <article ref={rootRef} data-look-id={look.id}
      className={"lb-look" + (showEdit ? " lb-edit" : "")
        + (showEdit && ec.selectedLookId === look.id ? " is-selected" : "")
        + (showEdit && look.hidden ? " is-hidden" : "")}
      onClick={showEdit ? () => ec.selectLook(look.id) : undefined}
      onContextMenu={openMenu}>
      {showEdit && look.hidden && <span className="lb-hidden-badge">HIDDEN</span>}
      {showEdit && (
        <div className="lb-cols-toggle" onClick={(e) => e.stopPropagation()}>
          <button className={cols === 1 ? "is-on" : ""} title="single image" onClick={() => ec.saveLook(look.id, { imgCols: 1 })}>1</button>
          <button className={cols === 2 ? "is-on" : ""} title="two images" onClick={() => ec.saveLook(look.id, { imgCols: 2 })}>2</button>
          {cols === 2 && realImages.length === 2 && (
            <button title="swap positions — the two photos trade places and the balance mirrors (70/30 → 30/70)"
              onClick={() => {
                const meta = (look.imageMeta || []).map((o) => ({ ...(o || {}) }));
                while (meta.length < 2) meta.push({ size: "", height: "" });
                const patch = {
                  images: [realImages[1], realImages[0]],
                  imageMeta: [meta[1], meta[0]],
                  imgSplit: 100 - split,   // balance follows the photos
                };
                // left/right photo scales ride along too (null = 100 / match-left)
                if (look.imgScale != null || look.imgScale2 != null) {
                  const v1 = look.imgScale != null ? Number(look.imgScale) : 100;
                  const v2 = look.imgScale2 != null ? Number(look.imgScale2) : v1;
                  patch.imgScale = v2; patch.imgScale2 = v1;
                }
                ec.saveLook(look.id, patch);
              }}>⇄</button>
          )}
        </div>
      )}
      <div className="lb-look-num">LOOK · {look.n}  ╱  <window.EditableText value={look.name} style={{ textTransform: "uppercase" }} onSave={(v) => ec.saveLook(look.id, { name: v })} /></div>

      <div ref={gridRef} className={"lb-look-grid" + (cols === 2 ? " lb-is-pair" : " lb-is-single")}
        style={{ gap: cols === 2 ? `${gap}px` : 0, height: `${height}px`, "--look-h": `${height}px` }}>
        {Array.from({ length: cols }).map((_, i) => renderFrame(i))}
        {showEdit && cols === 2 && (
          <div className="lb-divider-handle" style={{ left: `${split}%` }} onPointerDown={startDivider} title="drag to rebalance" />
        )}
        {showEdit && (
          <div className="lb-height-handle" onPointerDown={startHeight} title="drag to set height">⇕</div>
        )}
      </div>

      {menu && (
        <div className="lb-ctx" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
          {menu.frame != null && (
            <React.Fragment>
              <button onClick={menuAct(() => openLibraryAt(menu.frame))}>▤ search image…</button>
              {realImages[menu.frame] && (
                <button onClick={menuAct(async () => {
                  // copy the ORIGINAL file name (as uploaded) — the media
                  // library keeps it in `name`; disk basename is the fallback
                  const clean = String(realImages[menu.frame]).split("?")[0];
                  let name = clean.split("/").pop();
                  try {
                    window.__sbMediaNameIndex = window.__sbMediaNameIndex ||
                      window.sbAdmin.get("/api/admin/media").then((list) => {
                        const ix = {}; for (const m of list) if (m.name) ix[m.path] = m.name; return ix;
                      });
                    const ix = await window.__sbMediaNameIndex;
                    if (ix[clean]) name = ix[clean];
                  } catch (_) {}
                  if (navigator.clipboard) navigator.clipboard.writeText(name).catch(() => {});
                })}>⧉ copy file name</button>
              )}
            </React.Fragment>
          )}
          <button onClick={menuAct(() => ec.selectLook(look.id))}>edit details</button>
          <button onClick={menuAct(() => replaceRef.current && replaceRef.current.click())}>replace main photo</button>
          <button onClick={menuAct(() => ec.saveLook(look.id, { hidden: !look.hidden }))}>{look.hidden ? "show on storefront" : "hide from storefront"}</button>
          <button onClick={menuAct(() => ec.duplicateLook(look.id))}>duplicate</button>
          <button className="danger" onClick={menuAct(() => { if (confirm(`Delete look ${look.n} · ${look.name}?`)) ec.deleteLook(look.id); })}>delete</button>
        </div>
      )}
      {showEdit && (
        <input ref={replaceRef} type="file" accept="image/*" style={{ display: "none" }}
          onChange={(e) => { const f = e.target.files[0]; if (f) ec.upload(f, (path) => setLookImage(0, path)); e.target.value = ""; }} />
      )}
    </article>
  );
}

// ── Chapter / opener band ────────────────────────────────────────────────────
// A full-width editorial section: a background image (cover pan/zoom, per device)
// plus a title + body that you drag freely anywhere over it (Figma-style). Every
// position is a percentage stored PER DEVICE, so the composition reproduces at any
// screen size. Height is editable and can differ on mobile. name = title, body = text.
function ChapterBlock({ look, deviceOverride }) {
  const ec = React.useContext(window.EditCtx);
  const editing = ec && ec.edit;
  const showEdit = editing && !ec.preview;
  const bandRef = React.useRef(null);
  const bgSnap = React.useRef(null);   // pre-drag snapshot for image-transform undo
  const [menu, setMenu] = React.useState(null);
  const [vw, setVw] = React.useState(typeof window !== "undefined" ? window.innerWidth : 1280);
  React.useEffect(() => { const f = () => setVw(window.innerWidth); window.addEventListener("resize", f); return () => window.removeEventListener("resize", f); }, []);
  const device = editing ? (ec.device || "desktop") : (deviceOverride || deviceForVw(vw));

  const bm = look.blockMeta || {};
  const theme = bm.theme === "dark" ? "dark" : "light";
  const st = chStyle(look, device);   // { titleSize, textSize, align } for THIS device
  const height = resolveHeight(look, device, CH_DEFAULT_H);   // px at the device reference width
  const aspect = CH_REF_W[device] / height;                  // constant shape → overlays stick
  const img = (look.images || []).filter(Boolean)[0] || null;
  const tf = tfPick((look.imageMeta || [])[0], device);       // bg image cover transform
  const ix = tf.x == null ? 50 : tf.x, iy = tf.y == null ? 50 : tf.y, is = tf.s == null ? 1 : tf.s;
  const titleP = chPos(look, device, "title");
  const textP = chPos(look, device, "text");

  const setBg = (path) => { const arr = (look.images || []).slice(); arr[0] = path; ec.saveLook(look.id, { images: arr.filter(Boolean) }); };
  const setBgTransform = (ntf, commit) => {
    const m = (look.imageMeta || []).map((o) => ({ ...o }));
    while (m.length < 1) m.push({ size: "", height: "" });
    m[0] = { ...m[0], [device]: ntf };
    if (commit) { ec.saveLook(look.id, { imageMeta: m }, bgSnap.current ? { prev: bgSnap.current } : {}); bgSnap.current = null; }
    else { if (!bgSnap.current) bgSnap.current = look; ec.previewLook(look.id, { imageMeta: m }); }
  };

  // Write one per-device, per-element field set (position / align), preserving the
  // rest.
  const writeEl = (which, patch, commit, prev) => {
    const dev = { ...(bm[device] || {}) };
    dev[which] = { ...chPos(look, device, which), ...patch };
    const full = { blockMeta: { ...bm, [device]: dev } };
    if (commit) ec.saveLook(look.id, full, prev ? { prev } : {}); else ec.previewLook(look.id, full);
  };
  const setAlign = (which, al) => writeEl(which, { align: al }, true, look);
  // Drag a placed element (title|text): positions are % of the band, center-anchored.
  const startMove = (which) => (e) => {
    if (!showEdit) return;
    e.preventDefault(); e.stopPropagation();
    ec.selectLook(look.id);
    const snap = look;   // pre-drag snapshot → Cmd+Z reverts to here
    const r = bandRef.current.getBoundingClientRect();
    const start = chPos(look, device, which);
    let last = null;
    const onMove = (ev) => {
      const x = Math.max(0, Math.min(100, start.x + ((ev.clientX - e.clientX) / r.width) * 100));
      const y = Math.max(0, Math.min(100, start.y + ((ev.clientY - e.clientY) / r.height) * 100));
      last = { x, y }; writeEl(which, last, false);
    };
    const up = () => {
      window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", up);
      if (last) writeEl(which, last, true, snap);
    };
    window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", up);
  };

  // Height handle — the band renders by aspect-ratio, so convert the dragged pixel
  // height back to a "height at the device reference width" before storing.
  const startHeight = (e) => {
    e.preventDefault(); e.stopPropagation();
    const snap = look, rect = bandRef.current.getBoundingClientRect();
    const bandW = rect.width, startVisualH = rect.height, startY = e.clientY;
    const ref = CH_REF_W[device], hf = heightField(device);
    let stored = Math.round(startVisualH * ref / bandW);
    const onMove = (ev) => {
      const visual = Math.min(2400, Math.max(120, startVisualH + (ev.clientY - startY)));
      stored = Math.round(visual * ref / bandW);
      ec.previewLook(look.id, { [hf]: stored });
    };
    const up = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", up); ec.saveLook(look.id, { [hf]: stored }, { prev: snap }); };
    window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", up);
  };

  // Right-click context menu (parity with looks).
  const openMenu = (e) => { if (!showEdit) return; e.preventDefault(); e.stopPropagation(); ec.selectLook(look.id); setMenu(canvasMenuPos(e)); };
  React.useEffect(() => {
    if (!menu) return;
    const close = () => setMenu(null);
    const esc = (e) => { if (e.key === "Escape") close(); };
    window.addEventListener("click", close); window.addEventListener("scroll", close, true); window.addEventListener("keydown", esc);
    return () => { window.removeEventListener("click", close); window.removeEventListener("scroll", close, true); window.removeEventListener("keydown", esc); };
  }, [menu]);
  const menuAct = (fn) => (e) => { e.stopPropagation(); setMenu(null); fn(); };

  // A placed text element with its floating on-canvas toolbar (move grip + alignment).
  const placed = (which, align, p, children, extraStyle) => (
    <div className={"lb-ch-el lb-ch-el-" + which} style={{ left: p.x + "%", top: p.y + "%", textAlign: align, ...(extraStyle || {}) }}>
      {showEdit && (
        <div className="lb-ch-tools" onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
          <span className="lb-ch-grip" title="drag to move" onPointerDown={startMove(which)}>✛</span>
          <span className="lb-ch-tools-sep" />
          {["left", "center", "right"].map((al) => (
            <button key={al} className={"lb-ch-al" + (align === al ? " is-on" : "")} title={"align " + al}
              onPointerDown={(e) => e.stopPropagation()} onClick={() => setAlign(which, al)}>
              {al === "left" ? "⇤" : al === "right" ? "⇥" : "↔"}
            </button>
          ))}
        </div>
      )}
      {children}
    </div>
  );

  return (
    <section ref={bandRef} data-look-id={look.id} data-chapter="1"
      className={"lb-chapter lb-ch-" + theme + (showEdit ? " lb-edit" : "")
        + (showEdit && ec.selectedLookId === look.id ? " is-selected" : "")
        + (showEdit && look.hidden ? " is-hidden" : "")}
      style={{ aspectRatio: CH_REF_W[device] + " / " + height }}
      onClick={showEdit ? () => ec.selectLook(look.id) : undefined}
      onContextMenu={openMenu}
      {...(showEdit ? window.dropProps(ec, setBg) : {})}>
      {showEdit && look.hidden && <span className="lb-hidden-badge">HIDDEN</span>}
      {showEdit && <span className="lb-ch-badge">CHAPTER</span>}

      <div className="lb-ch-bg">
        {img
          ? <img className="photo-img" src={img} alt="" draggable={false} decoding="async"
              srcSet={window.sbSrcSet(img)}
              sizes={Math.min(200, Math.round(100 * is)) + "vw"}
              style={{ objectPosition: ix + "% " + iy + "%", transform: "scale(" + is + ")", transformOrigin: ix + "% " + iy + "%" }} />
          : <div className="photo-fill" />}
        {showEdit && img && (
          <TransformBox tf={tf} onChange={(ntf) => setBgTransform(ntf, false)} onCommit={(ntf) => setBgTransform(ntf, true)} />
        )}
        {showEdit && <window.EditableImage title={img ? "⤓ replace · drop" : "⤓ add image · drop"} onPick={setBg} />}
      </div>

      {placed("title", titleP.align || st.align, titleP, (
        <window.EditableText tag="h2" className="lb-ch-title" value={look.name}
          style={{ fontSize: (st.titleSize / CH_REF_W[device] * 100) + "cqw" }}
          placeholder="chapter title" onSave={(v) => ec.saveLook(look.id, { name: v })} />
      ))}
      {placed("text", textP.align || st.align, textP, (
        <window.EditableText tag="div" multiline className="lb-ch-text" value={look.body}
          style={{ fontSize: (st.textSize / CH_REF_W[device] * 100) + "cqw" }}
          placeholder="chapter text — a few editorial lines." onSave={(v) => ec.saveLook(look.id, { body: v })} />
      ), { width: (textP.w || 44) + "%" })}

      {showEdit && <div className="lb-height-handle lb-ch-height" onPointerDown={startHeight} title="drag to set height">⇕</div>}

      {menu && (
        <div className="lb-ctx" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
          <button onClick={menuAct(() => ec.selectLook(look.id))}>edit details</button>
          <button onClick={menuAct(() => ec.saveLook(look.id, { hidden: !look.hidden }))}>{look.hidden ? "show on storefront" : "hide from storefront"}</button>
          <button onClick={menuAct(() => ec.duplicateLook(look.id))}>duplicate</button>
          <button className="danger" onClick={menuAct(() => { if (confirm("Delete this chapter?")) ec.deleteLook(look.id); })}>delete</button>
        </div>
      )}
    </section>
  );
}
window.ChapterBlock = ChapterBlock;

// ── Canvas block ─────────────────────────────────────────────────────────────
// A freeform Figma-style band (kind:'canvas'): place images, styled text and
// tintable SVGs anywhere; each element carries per-device {x,y,w} percentages,
// an optional drop shadow, and z-order = array order. Height is editable per
// device like a chapter. All element data lives in blockMeta.els.
const CV_FONTS = {
  slender: '"Generic Slender", "Helvetica Neue", sans-serif',
  bulky: '"Generic Bulky", "Helvetica Neue", sans-serif',
  ui: 'var(--font-ui)',
  mono: 'var(--font-mono)',
};
// Selection is shared between the canvas (click/drag) and the inspector
// (list/properties) through a tiny window-level channel.
window.sbCanvasSel = window.sbCanvasSel || null;   // { lookId, elId } | null
window.sbCanvasSelect = (lookId, elId) => {
  window.sbCanvasSel = elId == null ? null : { lookId, elId };
  window.dispatchEvent(new CustomEvent("sb-canvas-sel"));
};
const useCanvasSel = (lookId) => {
  const [sel, setSel] = React.useState(window.sbCanvasSel);
  React.useEffect(() => {
    const f = () => setSel(window.sbCanvasSel);
    window.addEventListener("sb-canvas-sel", f);
    return () => window.removeEventListener("sb-canvas-sel", f);
  }, []);
  return sel && sel.lookId === lookId ? sel.elId : null;
};

// Inline an uploaded SVG so its fill is editable (an <img> can't be tinted).
// Fetched once per file (module cache), sanitized: scripts, event handlers,
// javascript: URLs and foreignObject are stripped before it touches the DOM.
const svgCache = {};
function SBInlineSvg({ src, tinted, stroke, strokeW }) {
  const clean = String(src || "").split("?")[0];
  const [markup, setMarkup] = React.useState(svgCache[clean] || null);
  React.useEffect(() => {
    if (!clean || svgCache[clean] != null) { setMarkup(svgCache[clean] || null); return; }
    let dead = false;
    fetch(src).then((r) => r.text()).then((t) => {
      let s = t.replace(/<script[\s\S]*?<\/script>/gi, "")
        .replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "")
        .replace(/\son\w+\s*=\s*"[^"]*"/gi, "").replace(/\son\w+\s*=\s*'[^']*'/gi, "")
        .replace(/(href|xlink:href)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, "")
        .replace(/<svg([^>]*)\swidth="[^"]*"/i, "<svg$1").replace(/<svg([^>]*)\sheight="[^"]*"/i, "<svg$1");
      svgCache[clean] = s;
      if (!dead) setMarkup(s);
    }).catch(() => { if (!dead) setMarkup(""); });
    return () => { dead = true; };
  }, [clean]);
  if (markup == null) return <span className="lb-cv-svg-loading">…</span>;
  const stroked = stroke != null || strokeW != null;
  return <span
    className={"lb-cv-svg" + (tinted ? " is-tinted" : "") + (stroked ? " is-stroked" : "")}
    style={stroked ? { "--cv-stroke": stroke || "currentColor", "--cv-stroke-w": (strokeW == null ? 2 : strokeW) } : undefined}
    dangerouslySetInnerHTML={{ __html: markup }} />;
}

function CanvasBlock({ look, deviceOverride }) {
  const ec = React.useContext(window.EditCtx);
  const editing = ec && ec.edit;
  const showEdit = editing && !ec.preview;
  const bandRef = React.useRef(null);
  const [menu, setMenu] = React.useState(null);
  const [vw, setVw] = React.useState(typeof window !== "undefined" ? window.innerWidth : 1280);
  React.useEffect(() => { const f = () => setVw(window.innerWidth); window.addEventListener("resize", f); return () => window.removeEventListener("resize", f); }, []);
  const device = editing ? (ec.device || "desktop") : (deviceOverride || deviceForVw(vw));

  const bm = look.blockMeta || {};
  const els = bm.els || [];
  const height = resolveHeight(look, device, CH_DEFAULT_H);   // px at the reference width
  const selId = useCanvasSel(look.id);

  // Per-device placement; mobile inherits desktop (scaled to the narrower band)
  // until it gets its own values, so a fresh canvas isn't empty on phones.
  const placeOf = (el) => el[device] || el.desktop || { x: 10, y: 10, w: 30 };
  // Text size behaves like chapters: stored in px AT THE DEVICE REFERENCE WIDTH
  // and rendered in cqw, so it scales with the band on any real screen. Mobile
  // keeps its own size (sizeM), falling back to the shared desktop size.
  const sizeField = device === "mobile" ? "sizeM" : "size";
  const sizeOf = (el) => (device === "mobile" && el.sizeM != null ? el.sizeM : el.size) || 40;
  const writeEls = (next, commit, prev) => {
    const full = { blockMeta: { ...bm, els: next } };
    if (commit) ec.saveLook(look.id, full, prev ? { prev } : {});
    else ec.previewLook(look.id, full);
  };
  const patchEl = (elId, patch, commit, prev) =>
    writeEls(els.map((e) => (e.id === elId ? { ...e, ...patch } : e)), commit, prev);
  const patchPlace = (elId, p, commit, prev) => {
    const el = els.find((e) => e.id === elId);
    if (!el) return;
    patchEl(elId, { [device]: { ...placeOf(el), ...p } }, commit, prev);
  };

  // Move via the ✛ grip (same language as chapter text) or by dragging an
  // image/svg directly; resize via TransformBox-style corner handles. Percent
  // coords of the band; text corner-resize scales the font size with the width.
  // While dragging, Figma-style SNAPPING: the element's edges + centre pull to
  // the canvas centre lines, the canvas edges, and every other layer's edges +
  // centres — red guides show what you're snapped to. Hold ⌥ to drag free.
  const [snapGuides, setSnapGuides] = React.useState(null);   // { v: %|null, h: %|null }
  const beginMove = (el) => (e) => {
    if (!showEdit) return;
    e.preventDefault(); e.stopPropagation();
    ec.selectLook(look.id);
    window.sbCanvasSelect(look.id, el.id);
    const snap = look, band = bandRef.current, r = band.getBoundingClientRect();
    const p = placeOf(el), sx = e.clientX, sy = e.clientY;
    const meNode = band.querySelector('[data-el="' + el.id + '"]');
    const me = meNode ? meNode.getBoundingClientRect() : null;
    // snap targets in viewport px: canvas edges + centre, other layers' edges + centres
    const others = els.filter((x) => x.id !== el.id)
      .map((x) => { const n = band.querySelector('[data-el="' + x.id + '"]'); return n ? n.getBoundingClientRect() : null; })
      .filter(Boolean);
    const xT = [r.left, r.left + r.width / 2, r.right, ...others.flatMap((o) => [o.left, o.left + o.width / 2, o.right])];
    const yT = [r.top, r.top + r.height / 2, r.bottom, ...others.flatMap((o) => [o.top, o.top + o.height / 2, o.bottom])];
    const TH = 6;   // px tolerance, ± around each target
    let last = null;
    const onMove = (ev) => {
      let px = me ? me.left + (ev.clientX - sx) : 0;
      let py = me ? me.top + (ev.clientY - sy) : 0;
      let gv = null, gh = null;
      if (me && !ev.altKey) {
        let bestX = TH + 1;
        for (const t of xT) for (const off of [0, me.width / 2, me.width]) {
          const d = t - (px + off);
          if (Math.abs(d) < Math.abs(bestX)) { bestX = d; gv = t; }
        }
        if (Math.abs(bestX) <= TH) px += bestX; else gv = null;
        let bestY = TH + 1;
        for (const t of yT) for (const off of [0, me.height / 2, me.height]) {
          const d = t - (py + off);
          if (Math.abs(d) < Math.abs(bestY)) { bestY = d; gh = t; }
        }
        if (Math.abs(bestY) <= TH) py += bestY; else gh = null;
      }
      const x = me ? ((px - r.left) / r.width) * 100 : p.x + ((ev.clientX - sx) / r.width) * 100;
      const y = me ? ((py - r.top) / r.height) * 100 : p.y + ((ev.clientY - sy) / r.height) * 100;
      last = { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
      setSnapGuides({
        v: gv != null ? ((gv - r.left) / r.width) * 100 : null,
        h: gh != null ? ((gh - r.top) / r.height) * 100 : null,
      });
      patchPlace(el.id, last, false);
    };
    const up = () => {
      window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", up);
      setSnapGuides(null);
      if (last) patchPlace(el.id, last, true, snap);
    };
    window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", up);
  };
  const beginResize = (el, corner) => (e) => {
    if (!showEdit) return;
    e.preventDefault(); e.stopPropagation();
    const snap = look, r = bandRef.current.getBoundingClientRect();
    const p = placeOf(el), sx = e.clientX;
    const west = corner === "nw" || corner === "sw";
    let last = null;
    const onMove = (ev) => {
      const dw = ((ev.clientX - sx) / r.width) * 100 * (west ? -1 : 1);
      const w = Math.min(200, Math.max(2, p.w + dw));
      last = { w: Math.round(w * 10) / 10 };
      if (west) last.x = Math.round((p.x + (p.w - w)) * 10) / 10;   // right edge stays put
      patchPlace(el.id, last, false);
      if (el.type === "text" && sizeOf(el)) {
        // font follows the box, Figma-style (per-device size on mobile)
        patchEl(el.id, { [sizeField]: Math.max(8, Math.round(sizeOf(el) * (w / p.w))) }, false);
      }
    };
    const up = () => {
      window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", up);
      if (last) {
        const full = els.map((x) => x.id !== el.id ? x : {
          ...x,
          ...(x.type === "text" && sizeOf(x) ? { [sizeField]: Math.max(8, Math.round(sizeOf(x) * (last.w / p.w))) } : {}),
          [device]: { ...placeOf(x), ...last },
        });
        writeEls(full, true, snap);
      }
    };
    window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", up);
  };

  // Drop straight onto the canvas: image/svg FILES land as elements at the
  // cursor; dragged/pasted TEXT becomes a text element; dragged/pasted SVG
  // MARKUP (e.g. Figma "copy as SVG") is uploaded and lands tintable.
  const dropPoint = (e) => {
    const r = bandRef.current.getBoundingClientRect();
    return { x: Math.round(((e.clientX - r.left) / r.width) * 1000) / 10, y: Math.round(((e.clientY - r.top) / r.height) * 1000) / 10 };
  };
  const addAt = (el, at) => {
    const withId = { ...el, id: "el" + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36), desktop: { w: el.w || 30, ...at } };
    delete withId.w;
    writeEls([...els, withId], true, look);
    window.sbCanvasSelect(look.id, withId.id);
  };
  const addSvgMarkup = (markup, at) => {
    const file = new File([markup], "pasted.svg", { type: "image/svg+xml" });
    ec.upload(file, (path) => addAt({ type: "svg", src: path, fill: null, w: 20 }, at));
  };
  const onCanvasDrop = (e) => {
    if (!showEdit) return;
    ec.selectLook(look.id);   // dropping targets this canvas — ⌘V etc. follow it
    const at = dropPoint(e);
    const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
    if (f) {
      e.preventDefault(); e.stopPropagation();
      if (/svg/i.test(f.type) || /\.svg$/i.test(f.name)) ec.upload(f, (path) => addAt({ type: "svg", src: path, fill: null, w: 20 }, at));
      else ec.upload(f, (path) => addAt({ type: "image", src: path, w: 42 }, at));
      return;
    }
    const txt = e.dataTransfer && e.dataTransfer.getData("text/plain");
    if (txt && txt.trim()) {
      e.preventDefault(); e.stopPropagation();
      if (/^\s*<svg[\s>]/i.test(txt)) addSvgMarkup(txt, at);
      else addAt({ type: "text", text: txt.trim(), font: "bulky", size: 64, tracking: 0, lineHeight: 1.1, color: "#ECE8E0", align: "left", upper: false, w: 50 }, at);
    }
  };
  // ⌘V while this canvas is selected (and no field focused): text / svg markup /
  // image files paste as new elements at the centre.
  React.useEffect(() => {
    if (!showEdit || ec.selectedLookId !== look.id) return;
    const onPaste = (e) => {
      const a = document.activeElement, tag = a && a.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA" || (a && a.isContentEditable)) return;
      const at = { x: 34, y: 38 };
      const items = (e.clipboardData && e.clipboardData.files) || [];
      if (items.length) {
        e.preventDefault();
        const f = items[0];
        if (/svg/i.test(f.type) || /\.svg$/i.test(f.name)) ec.upload(f, (path) => addAt({ type: "svg", src: path, fill: null, w: 20 }, at));
        else if (/^image\//.test(f.type)) ec.upload(f, (path) => addAt({ type: "image", src: path, w: 42 }, at));
        return;
      }
      const txt = e.clipboardData && e.clipboardData.getData("text/plain");
      if (!txt || !txt.trim()) return;
      e.preventDefault();
      if (/^\s*<svg[\s>]/i.test(txt)) addSvgMarkup(txt, at);
      else addAt({ type: "text", text: txt.trim(), font: "bulky", size: 64, tracking: 0, lineHeight: 1.1, color: "#ECE8E0", align: "left", upper: false, w: 50 }, at);
    };
    window.addEventListener("paste", onPaste);
    return () => window.removeEventListener("paste", onPaste);
  }, [showEdit, ec.selectedLookId, look.id, els]);

  // Band height drag (same mechanics as chapters).
  const startHeight = (e) => {
    e.preventDefault(); e.stopPropagation();
    const snap = look, rect = bandRef.current.getBoundingClientRect();
    const bandW = rect.width, startVisualH = rect.height, startY = e.clientY;
    const ref = CH_REF_W[device], hf = heightField(device);
    let stored = Math.round(startVisualH * ref / bandW);
    const onMove = (ev) => {
      const visual = Math.min(2400, Math.max(120, startVisualH + (ev.clientY - startY)));
      stored = Math.round(visual * ref / bandW);
      ec.previewLook(look.id, { [hf]: stored });
    };
    const up = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", up); ec.saveLook(look.id, { [hf]: stored }, { prev: snap }); };
    window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", up);
  };

  const openMenu = (e) => { if (!showEdit) return; e.preventDefault(); e.stopPropagation(); ec.selectLook(look.id); setMenu(canvasMenuPos(e)); };
  React.useEffect(() => {
    if (!menu) return;
    const close = () => setMenu(null);
    const esc = (e) => { if (e.key === "Escape") close(); };
    window.addEventListener("click", close); window.addEventListener("scroll", close, true); window.addEventListener("keydown", esc);
    return () => { window.removeEventListener("click", close); window.removeEventListener("scroll", close, true); window.removeEventListener("keydown", esc); };
  }, [menu]);
  const menuAct = (fn) => (e) => { e.stopPropagation(); setMenu(null); fn(); };

  // Font cycle for the floating bar (same order as the inspector).
  const CV_FONT_ORDER = ["bulky", "slender", "ui", "mono"];
  const renderEl = (el, i) => {
    const p = placeOf(el);
    const sh = el.shadow;
    const shadow = sh && (sh.blur != null || sh.x != null)
      ? `drop-shadow(${sh.x || 0}px ${sh.y || 0}px ${sh.blur || 0}px rgba(22,20,15,${sh.alpha == null ? 0.35 : sh.alpha}))`
      : null;
    const base = {
      position: "absolute", left: p.x + "%", top: p.y + "%", width: p.w + "%",
      zIndex: i + 1, filter: shadow || undefined,
    };
    const selected = showEdit && selId === el.id;
    let body = null;
    if (el.type === "image") {
      body = el.src ? <img src={window.sbImg(el.src, 1200)} srcSet={window.sbSrcSet(el.src)} sizes={p.w + "vw"} alt="" draggable={false} /> : <span className="lb-cv-empty">image</span>;
    } else if (el.type === "svg") {
      body = el.src ? <SBInlineSvg src={el.src} tinted={!!el.fill} stroke={el.stroke} strokeW={el.strokeW} /> : <span className="lb-cv-empty">svg</span>;
    } else {
      const textStyle = {
        fontFamily: CV_FONTS[el.font] || CV_FONTS.ui,
        // cqw like chapter text — size is "px at the device reference width"
        fontSize: (sizeOf(el) / CH_REF_W[device] * 100) + "cqw",
        fontWeight: el.weight || (el.font === "bulky" ? 900 : 400),
        letterSpacing: (el.tracking || 0) + "em",
        lineHeight: el.lineHeight || 1.2,
        color: el.color || "var(--sb-bone)",
        textAlign: el.align || "left",
        textTransform: el.upper ? "uppercase" : "none",
      };
      // edit in place, chapter-style (EditableText commits on blur)
      body = showEdit ? (
        <window.EditableText tag="div" multiline className="lb-cv-text" style={textStyle}
          value={el.text || ""} placeholder="text…"
          onSave={(v) => patchEl(el.id, { text: v }, true, look)} />
      ) : (
        <div className="lb-cv-text" style={textStyle}>{el.text || ""}</div>
      );
    }
    const CV_BAR_COLORS = ["#ECE8E0", "#16140f", "#ffffff"];
    const setColor = el.type === "svg"
      ? (c) => patchEl(el.id, { fill: c }, true, look)
      : (c) => patchEl(el.id, { color: c }, true, look);
    const curColor = el.type === "svg" ? el.fill : el.color;
    return (
      <div key={el.id} data-el={el.id}
        className={"lb-cv-el" + (showEdit ? " is-editable" : "") + (selected ? " is-selected" : "")}
        style={{ ...base, color: el.type === "svg" ? (el.fill || undefined) : undefined }}
        onPointerDownCapture={showEdit ? () => { ec.selectLook(look.id); window.sbCanvasSelect(look.id, el.id); } : undefined}
        onPointerDown={showEdit && el.type !== "text" ? beginMove(el) : undefined}
        onClick={showEdit ? (e) => e.stopPropagation() : undefined}>
        {/* floating design bar — reveals on hover like chapter text, stays while selected */}
        {showEdit && (
          <div className="lb-ch-tools lb-cv-tools" onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
            <span className="lb-ch-grip" title="drag to move" onPointerDown={beginMove(el)}>✛</span>
            {el.type === "text" && (
              <React.Fragment>
                <span className="lb-ch-tools-sep" />
                {["left", "center", "right"].map((al) => (
                  <button key={al} className={"lb-ch-al" + ((el.align || "left") === al ? " is-on" : "")} title={"align " + al}
                    onPointerDown={(e) => e.stopPropagation()} onClick={() => patchEl(el.id, { align: al }, true, look)}>
                    {al === "left" ? "⇤" : al === "right" ? "⇥" : "↔"}
                  </button>
                ))}
                <span className="lb-ch-tools-sep" />
                <button className="lb-cv-font" title="font"
                  onPointerDown={(e) => e.stopPropagation()}
                  onClick={() => patchEl(el.id, { font: CV_FONT_ORDER[(CV_FONT_ORDER.indexOf(el.font || "ui") + 1) % CV_FONT_ORDER.length] }, true, look)}>
                  {el.font === "ui" ? "inter" : (el.font || "inter")}
                </button>
                <button className="lb-ch-al" title="smaller" onPointerDown={(e) => e.stopPropagation()}
                  onClick={() => patchEl(el.id, { [sizeField]: Math.max(8, sizeOf(el) - 4) }, true, look)}>−</button>
                <button className="lb-ch-al" title="bigger" onPointerDown={(e) => e.stopPropagation()}
                  onClick={() => patchEl(el.id, { [sizeField]: sizeOf(el) + 4 }, true, look)}>+</button>
                <button className={"lb-ch-al" + (el.upper ? " is-on" : "")} title="uppercase" onPointerDown={(e) => e.stopPropagation()}
                  onClick={() => patchEl(el.id, { upper: !el.upper }, true, look)}>AA</button>
              </React.Fragment>
            )}
            {el.type !== "image" && (
              <React.Fragment>
                <span className="lb-ch-tools-sep" />
                {CV_BAR_COLORS.map((c) => (
                  <button key={c} className={"lb-cv-chip" + (curColor === c ? " is-on" : "")} style={{ background: c }}
                    title={el.type === "svg" ? "fill " + c : "colour " + c}
                    onPointerDown={(e) => e.stopPropagation()} onClick={() => setColor(c)} />
                ))}
                <label className="lb-cv-chip lb-cv-chip-custom" title="custom colour" onPointerDown={(e) => e.stopPropagation()}>
                  <input type="color" value={/^#[0-9a-f]{6}$/i.test(curColor || "") ? curColor : "#ECE8E0"}
                    onChange={(e) => setColor(e.target.value)} />
                </label>
              </React.Fragment>
            )}
          </div>
        )}
        {body}
        {/* TransformBox-style corner handles — resize like everywhere else */}
        {selected && ["nw", "ne", "sw", "se"].map((c) => (
          <span key={c} className={"lb-tbox-h lb-tbox-" + c} onPointerDown={beginResize(el, c)} />
        ))}
      </div>
    );
  };

  return (
    <section ref={bandRef} data-look-id={look.id} data-chapter="1"
      className={"lb-canvas" + (showEdit ? " lb-edit" : "")
        + (showEdit && ec.selectedLookId === look.id ? " is-selected" : "")
        + (showEdit && look.hidden ? " is-hidden" : "")}
      style={{ aspectRatio: CH_REF_W[device] + " / " + height, background: bm.bg || "var(--bg-2)" }}
      onClick={showEdit ? () => { ec.selectLook(look.id); window.sbCanvasSelect(look.id, null); } : undefined}
      onContextMenu={openMenu}
      onDragOver={showEdit ? (e) => { e.preventDefault(); } : undefined}
      onDrop={showEdit ? onCanvasDrop : undefined}>
      {showEdit && look.hidden && <span className="lb-hidden-badge">HIDDEN</span>}
      {showEdit && <span className="lb-ch-badge">CANVAS</span>}
      {els.map(renderEl)}
      {/* red snap guides while dragging (Figma language) */}
      {showEdit && snapGuides && snapGuides.v != null && <span className="lb-cv-guide lb-cv-guide-v" style={{ left: snapGuides.v + "%" }} />}
      {showEdit && snapGuides && snapGuides.h != null && <span className="lb-cv-guide lb-cv-guide-h" style={{ top: snapGuides.h + "%" }} />}
      {showEdit && els.length === 0 && (
        <div className="lb-cv-hint">empty canvas — add images, text or an svg from the inspector →</div>
      )}
      {showEdit && <div className="lb-height-handle lb-ch-height" onPointerDown={startHeight} title="drag to set height">⇕</div>}
      {menu && (
        <div className="lb-ctx" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
          <button onClick={menuAct(() => ec.selectLook(look.id))}>edit details</button>
          <button onClick={menuAct(() => ec.saveLook(look.id, { hidden: !look.hidden }))}>{look.hidden ? "show on storefront" : "hide from storefront"}</button>
          <button onClick={menuAct(() => ec.duplicateLook(look.id))}>duplicate</button>
          <button className="danger" onClick={menuAct(() => { if (confirm("Delete this canvas?")) ec.deleteLook(look.id); })}>delete</button>
        </div>
      )}
    </section>
  );
}
window.CanvasBlock = CanvasBlock;

// One photo's content row inside the indicator (its products + model meta).
function LookIndContent({ skus, metas, innerRef, className }) {
  return (
    <div className={"lb-ind-content " + (className || "")} ref={innerRef}>
      {skus.length > 0 && (
        <span className="lb-ind-skus">
          {skus.map((s) => (
            <span className="lb-ind-sku" key={s.id}>
              {s.image
                ? <img className="lb-ind-thumb" src={s.image} alt="" />
                : <span className="lb-ind-thumb lb-ind-thumb-color" style={{ background: (s.colorway && s.colorway.bg) || "var(--bg-3)" }} />}
              <span className="lb-ind-name">{s.name.toLowerCase()}</span>
            </span>
          ))}
        </span>
      )}
      {metas.length > 0 && <span className="lb-ind-meta">{metas.join("   /   ")}</span>}
    </div>
  );
}

// Floating, frosted indicator (bottom-center). Shows the products + model
// size/height of the photo you're hovering (scroll-centered fallback for touch).
// Morphs Dynamic-Island style between photos. desc = {key, skus, metas} | null.
function LookIndicator({ desc, onClick }) {
  const [shown, setShown] = React.useState(desc);
  const [leaving, setLeaving] = React.useState(null);
  const [w, setW] = React.useState(null);
  const [exiting, setExiting] = React.useState(false);   // true while playing the out animation
  const shownRef = React.useRef(shown);
  const nodeRef = React.useRef(null);
  const setNode = React.useCallback((el) => { nodeRef.current = el; if (el) setW(el.scrollWidth + 44); }, []);

  // Content present → morph in / cross-fade. Content gone → play the out animation,
  // then unmount (so appearing again re-triggers the pop-in).
  React.useEffect(() => {
    if (desc) {
      const cur = shownRef.current;
      if (cur && cur.key !== desc.key) setLeaving(cur);
      shownRef.current = desc; setShown(desc); setExiting(false);
    } else if (shownRef.current) {
      setExiting(true);
      const t = setTimeout(() => { setExiting(false); setLeaving(null); shownRef.current = null; setShown(null); }, 440);
      return () => clearTimeout(t);
    }
  }, [desc && desc.key]);
  React.useEffect(() => {
    if (!leaving) return;
    const t = setTimeout(() => setLeaving(null), 480);
    return () => clearTimeout(t);
  }, [leaving]);
  React.useEffect(() => {
    const onResize = () => { if (nodeRef.current) setW(nodeRef.current.scrollWidth + 44); };
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  const empty = (d) => !d || (!d.skus.length && !d.metas.length);
  if (empty(shown)) return null;
  // The wrapper carries the fixed positioning + a soft frosted scrim (::before)
  // that fades the content around the pill, so look-border seams behind the
  // floating bubble don't show a hard line through its shadow. The scrim lives on
  // the wrapper (not the pill) because the pill clips its own content (overflow).
  return (
    <div className={"lb-ind-wrap" + (exiting ? " is-exiting" : "")}>
      <div className={"lb-indicator" + (onClick ? " lb-indicator-click" : "") + (shown.chapter ? " is-chapter" : "")}
        style={{ width: w ? w + "px" : undefined }}
        onClick={onClick || undefined} role={onClick ? "button" : undefined}>
        {leaving && !empty(leaving) && (!shown || leaving.key !== shown.key) && (
          <LookIndContent key={"leave-" + leaving.key} skus={leaving.skus} metas={leaving.metas} className="lb-ind-leaving" />
        )}
        <LookIndContent key={"cur-" + shown.key} skus={shown.skus} metas={shown.metas} innerRef={setNode} className="lb-ind-current" />
      </div>
    </div>
  );
}

function LookbookView({ looks, collection, onOpenSku, onOpenLook, lookPageOpen }) {
  const ec = React.useContext(window.EditCtx);
  const editingActive = ec && ec.edit && !ec.preview;

  // ── LOCKED COMPOSITION ────────────────────────────────────────────────────
  // The feed lays out at the device's fixed reference width (CH_REF_W), so the
  // crops you set in the editor are frozen. Retailers get the whole canvas
  // scaled uniformly to their window (smaller window = smaller book, never a
  // different crop; windows wider than the canvas centre it). The editor keeps
  // scale 1 — a too-small window reveals via horizontal scroll. Device
  // breakpoints (mobile / desktop) still switch which composition shows.
  const [lockVw, setLockVw] = React.useState(typeof window !== "undefined" ? window.innerWidth : 1280);
  const [lockVh, setLockVh] = React.useState(typeof window !== "undefined" ? window.innerHeight : 800);
  React.useEffect(() => {
    const f = () => {
      const w = window.innerWidth, h = window.innerHeight;
      setLockVw(w);
      // lockVh only feeds the portrait/landscape split. iOS fires height-only
      // resizes as its toolbar hides/shows mid-scroll — committing those would
      // re-render the whole feed at the exact moment the user reverses scroll
      // direction (visible stutter). Only commit when the split actually flips.
      setLockVh((prev) => ((w > h) === (w > prev) ? prev : h));
    };
    window.addEventListener("resize", f);
    window.addEventListener("orientationchange", f);
    return () => { window.removeEventListener("resize", f); window.removeEventListener("orientationchange", f); };
  }, []);
  // ── mobile FEED view — the desktop composition scaled down, so several looks
  // fit one screen. Portrait: an opt-in toggle. Landscape: the default and the
  // ONLY mode. Model-height labels hide in portrait feed, show in landscape.
  const retail = !(ec && ec.edit);
  const mobileBand = retail && deviceForVw(lockVw) === "mobile";
  const landscape = lockVw > lockVh;
  const [feedPref, setFeedPref] = React.useState(() => { try { return localStorage.getItem("sb-lb-feed") === "1"; } catch (_) { return false; } });
  const toggleFeed = () => {
    // remember the block at the viewport centre — after the mode flip every
    // height changes, so we re-anchor the scroll to the same look (twice: once
    // right after the re-render, once after sizes settle)
    const mid = window.innerHeight / 2;
    let anchor = null, best = Infinity;
    for (const b of document.querySelectorAll(".lb-look[data-look-id], .lb-chapter[data-look-id], .lb-canvas[data-look-id]")) {
      const r = b.getBoundingClientRect();
      if (r.bottom < 0 || r.top > window.innerHeight) continue;
      const d = Math.abs((r.top + r.bottom) / 2 - mid);
      if (d < best) { best = d; anchor = b.getAttribute("data-look-id"); }
    }
    setFeedPref((v) => { try { localStorage.setItem("sb-lb-feed", v ? "0" : "1"); } catch (_) {} return !v; });
    if (anchor != null) {
      const re = () => {
        const el = document.querySelector(`.lb-look[data-look-id="${anchor}"], .lb-chapter[data-look-id="${anchor}"]`);
        if (el) el.scrollIntoView({ block: "center" });
      };
      setTimeout(re, 80); setTimeout(re, 420);
    }
  };
  const feedMode = mobileBand && (landscape || feedPref);
  // the phone @media rules restyle look internals (stacked pairs etc.) — while
  // ZOOMED OUT the canvas must keep the exact desktop structure, so those rules
  // are gated on body:not(.sb-zoomout)
  React.useEffect(() => {
    document.body.classList.toggle("sb-zoomout", feedMode);
    return () => document.body.classList.remove("sb-zoomout");
  }, [feedMode]);
  const lockSecRef = React.useRef(null);
  const lockCanvasRef = React.useRef(null);
  const [lockCw, setLockCw] = React.useState(0);   // available container width
  const [lockCh, setLockCh] = React.useState(0);   // canvas natural height
  // Measure synchronously on every commit (setState bails when unchanged) and
  // on resize; ResizeObserver covers async changes (images loading in).
  const lockMeasure = React.useCallback(() => {
    if (lockSecRef.current) setLockCw(lockSecRef.current.clientWidth);
    if (lockCanvasRef.current) setLockCh(lockCanvasRef.current.offsetHeight);
  }, []);
  React.useLayoutEffect(lockMeasure);
  React.useEffect(() => {
    window.addEventListener("resize", lockMeasure);
    let ro = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(lockMeasure);
      if (lockSecRef.current) ro.observe(lockSecRef.current);
      if (lockCanvasRef.current) ro.observe(lockCanvasRef.current);
    }
    return () => { window.removeEventListener("resize", lockMeasure); if (ro) ro.disconnect(); };
  }, [lockMeasure, looks.length]);
  const lockDev = (ec && ec.edit) ? (ec.device || "desktop") : (feedMode ? "desktop" : deviceForVw(lockVw));
  const lockRefW = CH_REF_W[lockDev] || CH_REF_W.desktop;
  // mobile (retail) scales freely in both directions (430 canvas fills up to
  // 900px windows); desktop — and the EDITOR — never upscales: wider
  // containers centre the canvas, narrower ones shrink it to fit.
  const lockScale = (!ec || !ec.edit) && lockDev === "mobile"
    ? (lockCw || lockRefW) / lockRefW
    : Math.min(1, (lockCw || lockRefW) / lockRefW);
  // Mobile vs desktop drives where the "open look page" tap lives: image on
  // desktop, indicator on mobile. In edit/preview we follow the device toggle;
  // for retailers we detect touch (no hover).
  const isMobile = (ec && ec.edit)
    ? (ec.device === "mobile")
    : (typeof window !== "undefined" && window.matchMedia && !window.matchMedia("(hover: hover)").matches);
  const [hover, setHover] = React.useState(null);    // {lookId, frame} from photo hover
  const [centered, setCentered] = React.useState(null);
  const onHoverImage = React.useCallback((lookId, frame) => setHover(lookId == null ? null : { lookId, frame }), []);

  // Scroll fallback (touch / no hover): the photo crossing the viewport centre.
  React.useEffect(() => {
    if (editingActive) return;
    let raf = 0;
    const compute = () => {
      raf = 0;
      // Trigger line sits lower than centre (0.68 of the viewport) so the next
      // photo becomes the active one earlier as you scroll (matters on touch,
      // where there's no hover to drive the indicator).
      const midY = window.innerHeight * 0.68, midX = window.innerWidth / 2;
      let best = null, bestScore = Infinity;
      for (const f of document.querySelectorAll(".lb-frame")) {
        const r = f.getBoundingClientRect();
        if (r.bottom < 0 || r.top > window.innerHeight) continue;
        const crosses = r.top <= midY && r.bottom >= midY;
        const score = (crosses ? 0 : Math.abs((r.top + r.bottom) / 2 - midY) + 1e6) + Math.abs((r.left + r.right) / 2 - midX) * 0.01;
        if (score < bestScore) { bestScore = score; best = { lookId: Number(f.getAttribute("data-look-id")), frame: Number(f.getAttribute("data-frame")) }; }
      }
      // Chapters compete for the trigger line but never SHOW the bubble — if a
      // chapter is the block at the line, suppress it (no bubble on a chapter page).
      for (const c of document.querySelectorAll(".lb-chapter[data-look-id], .lb-canvas[data-look-id]")) {
        const r = c.getBoundingClientRect();
        if (r.bottom < 0 || r.top > window.innerHeight) continue;
        const crosses = r.top <= midY && r.bottom >= midY;
        const score = crosses ? 0 : Math.abs((r.top + r.bottom) / 2 - midY) + 1e6;
        if (score < bestScore) { bestScore = score; best = null; }   // chapter wins → hide bubble
      }
      setCentered(best);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); };
    compute();
    window.addEventListener("scroll", onScroll, true);
    window.addEventListener("resize", onScroll);
    return () => { window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [looks, editingActive]);

  // Edit mode: as you scroll, auto-select whichever block (look or chapter) sits
  // nearest the upper-third of the viewport, so the inspector follows you and you
  // never have to re-pick a look. Skipped while typing in a field (so edits stick).
  // selectedLookId is read via a ref (not a dep) so the watcher only reacts to real
  // scrolling — a manual rail click then holds until you next scroll.
  const selRef = React.useRef(ec && ec.selectedLookId);
  React.useEffect(() => { selRef.current = ec && ec.selectedLookId; }, [ec && ec.selectedLookId]);
  // Typing guard: while a field is focused we only pause the follow if the
  // user actually typed in the last moment — an inspector input keeps focus
  // forever after an edit, which used to freeze the selection until a click.
  const lastKeyRef = React.useRef(0);
  React.useEffect(() => {
    if (!editingActive) return;
    const onKey = () => { lastKeyRef.current = Date.now(); };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, [editingActive]);
  React.useEffect(() => {
    if (!editingActive) return;
    let raf = 0;
    const compute = () => {
      raf = 0;
      const el = document.activeElement, tag = el && el.tagName;
      const typing = tag === "INPUT" || tag === "TEXTAREA" || (el && el.isContentEditable);
      if (typing) {
        if (Date.now() - lastKeyRef.current < 1200) return;   // actively typing — hold
        el.blur();   // idle leftover focus: commit the field, then follow the scroll
      }
      const tops = [...document.querySelectorAll(".lb-look[data-look-id], .lb-chapter[data-look-id], .lb-canvas[data-look-id]")];
      if (!tops.length) return;
      const mid = window.innerHeight * 0.4;
      let best = null, bestScore = Infinity;
      for (const b of tops) {
        const r = b.getBoundingClientRect();
        if (r.bottom < 0 || r.top > window.innerHeight) continue;
        const score = Math.abs((r.top + r.bottom) / 2 - mid);
        if (score < bestScore) { bestScore = score; best = Number(b.getAttribute("data-look-id")); }
      }
      if (best != null && best !== selRef.current) ec.selectLook(best);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); };
    compute();
    window.addEventListener("scroll", onScroll, true);
    window.addEventListener("resize", onScroll);
    return () => { window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [looks, editingActive]);

  // No scroll magnet. The lookbook scrolls FREE, like a magazine feed — every
  // snap variant tried (mandatory, proximity, per-frame, per-look) fought iOS
  // momentum and felt catchy on device. The 86dvh cap already keeps each look
  // within one screen, so free scrolling reads naturally. (The `lb-snap` CSS
  // has been removed with this.)

  const active = hover || centered;
  let desc = null;
  if (active) {
    const lk = looks.find((l) => l.id === active.lookId);
    if (lk && lk.kind !== "chapter") {
      const m = (lk.imageMeta || [])[active.frame] || {};
      const skus = (Array.isArray(m.skuIds) ? m.skuIds : []).map((id) => collection.find((p) => p.id === id)).filter(Boolean);
      const metas = (m.size || m.height) ? [[m.size ? `WEARS ${m.size}` : "", m.height ? `MODEL IS ${fmtHeight(m.height)}` : ""].filter(Boolean).join(" · ")] : [];
      desc = { key: active.lookId + "-" + active.frame, skus, metas };
    }
  }

  React.useEffect(() => {
    const PAGE_BG = 236;
    const lum = (r, g, b) => 0.299 * r + 0.587 * g + 0.114 * b;
    const ctxFor = (img) => {
      if (img._lumCtx !== undefined) return img._lumCtx;
      try {
        const c = document.createElement("canvas");
        c.width = img.naturalWidth; c.height = img.naturalHeight;
        const ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0); img._lumCtx = ctx;
      } catch (e) { img._lumCtx = null; }
      return img._lumCtx;
    };
    const paint = () => {
      document.querySelectorAll(".lb-look").forEach((look) => {
        const label = look.querySelector(".lb-look-num");
        if (!label) return;
        const lr = label.getBoundingClientRect();
        if (!lr.width) return;
        const imgs = [...look.querySelectorAll(".photo-img")].filter((im) => im.naturalWidth);
        const N = 6, M = 3; let total = 0, count = 0;
        for (let i = 0; i < N; i++) for (let j = 0; j < M; j++) {
          const x = lr.left + (lr.width * (j + 0.5)) / M;
          const y = lr.top + (lr.height * (i + 0.5)) / N;
          let sampled = false;
          for (const im of imgs) {
            const ir = im.getBoundingClientRect();
            if (x >= ir.left && x <= ir.right && y >= ir.top && y <= ir.bottom) {
              const ctx = ctxFor(im);
              if (ctx) {
                const px = Math.min(im.naturalWidth - 1, Math.max(0, Math.floor(((x - ir.left) / ir.width) * im.naturalWidth)));
                const py = Math.min(im.naturalHeight - 1, Math.max(0, Math.floor(((y - ir.top) / ir.height) * im.naturalHeight)));
                try { const d = ctx.getImageData(px, py, 1, 1).data; total += lum(d[0], d[1], d[2]); count++; sampled = true; } catch (e) {}
              }
              break;
            }
          }
          if (!sampled) { total += PAGE_BG; count++; }
        }
        if (!count) return;
        const dark = total / count > 140;
        label.style.mixBlendMode = "normal";
        label.style.color = dark ? "#16140f" : "#ffffff";
        label.style.textShadow = dark ? "0 0 3px rgba(255,255,255,0.55)" : "0 0 3px rgba(0,0,0,0.55)";
      });
    };
    paint();
    window.addEventListener("resize", paint);
    const imgs = [...document.querySelectorAll(".lb-look .photo-img")];
    imgs.forEach((im) => im.addEventListener("load", paint));
    return () => {
      window.removeEventListener("resize", paint);
      imgs.forEach((im) => im.removeEventListener("load", paint));
    };
  }, [looks]);

  return (
    <section className="lb" ref={lockSecRef} data-screen-label="02 Lookbook">
      {/* fixed-width canvas: editor reveals (h-scroll), retailers scale.
          The indicator pill lives OUTSIDE the scaled tree so it never shrinks. */}
      <div className="lb-lock-outer"
        style={{ height: lockCh ? Math.ceil(lockCh * lockScale) : undefined, overflow: "hidden" }}>
        <div className={"lb-lock-canvas" + (feedMode ? " is-feed" : "") + (feedMode && !landscape ? " is-feed-quiet" : "")}
          ref={lockCanvasRef}
          style={{
            width: lockRefW,
            // portrait feed: labels scale WITH the canvas (a zoomed-out page);
            // otherwise they counter-scale to stay at real size
            "--lb-inv": feedMode && !landscape ? 1 : +(1 / lockScale).toFixed(4),
            // scale() is ALWAYS applied (even at 1) so fixed-position children
            // (context menus) consistently use the canvas as containing block
            transform: `scale(${lockScale})`,
            transformOrigin: "top left",
            margin: lockScale === 1 ? "0 auto" : 0,
          }}>
          {/* phone header safe zone: on real phones the retail header floats OVER
              the opening image (fixed hero overlay) — mark the covered band so
              compositions keep faces/type clear of it */}
          {editingActive && ec.device === "mobile" && looks.length > 0 && (
            <div className="lb-mob-safe" aria-hidden="true">
              <span className="lb-mob-safe-label">covered by the phone header</span>
            </div>
          )}
          {looks.map((look) => (
            look.kind === "chapter"
              ? <ChapterBlock key={look.id} look={look} deviceOverride={feedMode ? "desktop" : null} />
              : look.kind === "canvas"
              ? <CanvasBlock key={look.id} look={look} deviceOverride={feedMode ? "desktop" : null} />
              : <LookCard key={look.id} look={look} collection={collection} onOpenSku={onOpenSku}
                  onHoverImage={onHoverImage} deviceOverride={feedMode ? "desktop" : null}
                  onOpenImage={onOpenLook} />
          ))}
        </div>
      </div>
      {!editingActive && !lookPageOpen && (
        <LookIndicator desc={feedMode ? null : desc}
          onClick={active && !active.chapter ? () => onOpenLook(active.lookId, active.frame) : null} />
      )}
      {mobileBand && !landscape && !lookPageOpen && (
        <button type="button" className="lb-feed-toggle" onClick={toggleFeed}
          aria-label={feedMode ? "mobile view" : "zoom out"}
          title={feedMode ? "back to the native mobile view" : "zoom out — the desktop layout, smaller"}>
          {feedMode ? (
            /* phone outline — back to the native 1:1 view */
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
              <rect x="4.5" y="1.5" width="7" height="13" />
              <line x1="6.7" y1="12.3" x2="9.3" y2="12.3" />
            </svg>
          ) : (
            /* magnifier with − — zoom out to the spread overview */
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
              <circle cx="6.8" cy="6.8" r="4.6" />
              <line x1="10.2" y1="10.2" x2="14" y2="14" />
              <line x1="4.7" y1="6.8" x2="8.9" y2="6.8" />
            </svg>
          )}
        </button>
      )}
    </section>
  );
}

window.LookbookView = LookbookView;
