// LookbookEditor.jsx — storefront, edit-mode-only editor chrome for the lookbook:
//   · left rail   — outline of looks: add, drag-reorder, hide/show, delete, select,
//                   plus publish/discard/undo
//   · right panel — inspector for the selected look (name/body, photos, composition)
// On-canvas drag handles (balance, height) and the 1/2 toggle live in Lookbook.jsx.
// Only mounts when ?edit=1 on the lookbook view. Retailers never load it.

function ledArrayMove(arr, from, to) {
  const next = arr.slice();
  const [moved] = next.splice(from, 1);
  next.splice(to, 0, moved);
  return next;
}

// Tiny reusable index-based drag-reorder hook for vertical lists in the inspector.
function useListDrag(onCommit) {
  const from = React.useRef(null);
  const [dragIdx, setDragIdx] = React.useState(null);
  return {
    dragIdx,
    rowProps: (i) => ({
      draggable: true,
      onDragStart: (e) => { from.current = i; setDragIdx(i); e.dataTransfer.effectAllowed = "move"; },
      onDragOver: (e) => {
        e.preventDefault();
        if (from.current == null || from.current === i) return;
        const f = from.current; from.current = i; setDragIdx(i); onCommit(f, i);
      },
      onDragEnd: () => { from.current = null; setDragIdx(null); },
      onDrop: (e) => e.preventDefault(),
    }),
  };
}

// Per-photo composition picker (products shown for one photo). Drag to reorder,
// click a name to swap, × to remove, dropdown to add.
function PhotoComposition({ skuIds, collection, onChange }) {
  const [adding, setAdding] = React.useState(false);
  const [q, setQ] = React.useState("");
  const drag = useListDrag((f, t) => onChange(ledArrayMove(skuIds, f, t)));
  const skuById = {};
  for (const s of collection) skuById[s.id] = s;
  const add = (id) => { if (id && !skuIds.includes(id)) onChange([...skuIds, id]); };
  const remove = (id) => onChange(skuIds.filter((x) => x !== id));
  const term = q.trim().toLowerCase();
  const results = collection.filter((s) => {
    const hay = (s.name + " " + (s.colorway ? s.colorway.name : "") + " " + (s.idx || "")).toLowerCase();
    return !term || hay.includes(term);
  });
  const thumbOf = (p) => ({ background: p ? p.bg : "var(--bg-3)" });
  return (
    <div className="sb-insp-photocomp">
      <div className="sb-insp-photocomp-h">products in this photo · {skuIds.length}</div>
      {skuIds.map((id, i) => {
        const p = skuById[id];
        return (
          <div key={id} className={"sb-insp-comp" + (drag.dragIdx === i ? " is-dragging" : "")} {...drag.rowProps(i)}>
            <span className="sb-insp-grip" title="drag to reorder">⠿</span>
            <span className="sb-insp-thumb sm" style={thumbOf(p)} />
            <span className="sb-insp-comp-name">
              {p ? p.name.toLowerCase() : id}
              {p && p.colorway ? <span className="sb-insp-comp-cw"> · {p.colorway.name}</span> : null}
            </span>
            <button className="sb-insp-comp-x danger" title="remove" onClick={() => remove(id)}>×</button>
          </div>
        );
      })}
      {/* Add products: opens an inline picker that stays open (multi-add) with a
          live search; each row shows the product image, name + colour. Adds land
          immediately, and the picker closes on "done". */}
      <button className={"sb-insp-addbtn" + (adding ? " is-open" : "")}
        onClick={() => { setAdding((a) => !a); setQ(""); }}>
        {adding ? "done ✓" : "+ add products…"}
      </button>
      {adding && (
        <div className="sb-insp-picker">
          <input className="sb-insp-search" autoFocus placeholder="search name or colour…"
            value={q} onChange={(e) => setQ(e.target.value)} />
          <div className="sb-insp-picker-list">
            {results.map((s) => {
              const added = skuIds.includes(s.id);
              return (
                <button key={s.id} className={"sb-insp-pick" + (added ? " is-added" : "")}
                  onClick={() => (added ? remove(s.id) : add(s.id))}>
                  <span className="sb-insp-pick-thumb" style={thumbOf(s)} />
                  <span className="sb-insp-pick-txt">
                    <span className="sb-insp-pick-name">{s.name.toLowerCase()}</span>
                    {s.colorway ? <span className="sb-insp-pick-cw">{s.colorway.name}</span> : null}
                  </span>
                  <span className="sb-insp-pick-tick">{added ? "✓" : "+"}</span>
                </button>
              );
            })}
            {results.length === 0 && <div className="sb-insp-pick-empty">no match.</div>}
          </div>
        </div>
      )}
    </div>
  );
}

function LookInspector({ look, collection, device, onPatch, onPreview, onUpload }) {
  const LB_DEF_H = Math.round(window.innerHeight * 0.78);
  const [form, setForm] = React.useState(() => ({
    n: look.n || "", name: look.name || "", body: look.body || "",
    images: Array.isArray(look.images) ? look.images.filter(Boolean) : [],
    imageMeta: Array.isArray(look.imageMeta) ? look.imageMeta.slice() : [],
    imgGap: look.imgGap == null ? 24 : Number(look.imgGap),
    imgSplit: look.imgSplit == null ? 50 : Number(look.imgSplit),
    imgHeight: look.imgHeight == null ? LB_DEF_H : Number(look.imgHeight),
    imgHeightMobile: look.imgHeightMobile == null ? null : Number(look.imgHeightMobile),
  }));
  const [uploading, setUploading] = React.useState(false);
  const set = (patch) => setForm((f) => ({ ...f, ...patch }));
  // Photos can change OUTSIDE this panel while it stays mounted (sequence-mode
  // swaps/replaces on the selected look) — mirror them in, or the panel shows
  // stale photos and a later commit would write the old images back.
  React.useEffect(() => {
    setForm((f) => ({
      ...f,
      images: Array.isArray(look.images) ? look.images.filter(Boolean) : [],
      imageMeta: Array.isArray(look.imageMeta) ? look.imageMeta.slice() : [],
    }));
  }, [look.images, look.imageMeta]);

  const skuById = {};
  for (const s of collection) skuById[s.id] = s;
  const cols = look.imgCols != null ? look.imgCols : (form.images.length >= 2 ? 2 : 1);

  // Photos -------------------------------------------------------------------
  const persistImages = (images, imageMeta) => onPatch(look.id, { images, imageMeta });
  const addPhoto = async (file) => {
    if (form.images.length >= 2) return;
    setUploading(true);
    await onUpload(file, (path) => {
      const images = [...form.images, path].slice(0, 2);
      const imageMeta = [...form.imageMeta]; while (imageMeta.length < images.length) imageMeta.push({ size: "", height: "" });
      set({ images, imageMeta }); persistImages(images, imageMeta);
    });
    setUploading(false);
  };
  const replacePhoto = async (i, file) => {
    setUploading(true);
    await onUpload(file, (path) => { const images = form.images.slice(); images[i] = path; set({ images }); persistImages(images, form.imageMeta); });
    setUploading(false);
  };
  const removePhoto = (i) => {
    const images = form.images.filter((_, k) => k !== i);
    const imageMeta = form.imageMeta.filter((_, k) => k !== i);
    set({ images, imageMeta }); persistImages(images, imageMeta);
  };
  const reorderPhotos = (f, t) => {
    const images = ledArrayMove(form.images, f, t);
    const meta = form.imageMeta.slice(); while (meta.length < form.images.length) meta.push({ size: "", height: "" });
    const imageMeta = ledArrayMove(meta, f, t);
    set({ images, imageMeta }); persistImages(images, imageMeta);
  };
  const setMeta = (i, field, value) => {
    const imageMeta = form.imageMeta.slice(); while (imageMeta.length <= i) imageMeta.push({ size: "", height: "" });
    imageMeta[i] = { ...imageMeta[i], [field]: value }; set({ imageMeta });
  };
  const commitMeta = () => persistImages(form.images, form.imageMeta);
  const photoDrag = useListDrag(reorderPhotos);

  // Per-photo composition ----------------------------------------------------
  const setPhotoSkus = (i, skuIds) => {
    const imageMeta = form.imageMeta.slice();
    while (imageMeta.length <= i) imageMeta.push({ size: "", height: "" });
    imageMeta[i] = { ...imageMeta[i], skuIds };
    set({ imageMeta });
    onPatch(look.id, { imageMeta });
  };

  // Layout sliders (always available, even while transform boxes are active).
  const layoutSlider = (field, label, min, max, step, unit) => (
    <div className="sb-insp-range">
      <label>{label}<span className="sb-insp-range-val">{form[field]}{unit}</span></label>
      <input type="range" min={min} max={max} step={step} value={form[field]}
        onChange={(e) => { const v = Number(e.target.value); set({ [field]: v }); onPreview(look.id, { [field]: v }); }}
        onMouseUp={(e) => onPatch(look.id, { [field]: Number(e.target.value) })}
        onTouchEnd={(e) => onPatch(look.id, { [field]: Number(e.target.value) })} />
    </div>
  );

  return (
    <div className="sb-insp-body">
      <div className="sb-insp-sec">
        <div className="sb-insp-row-2">
          <div className="sb-insp-field" style={{ maxWidth: 84 }}>
            <label>number</label>
            <input value={form.n} onChange={(e) => set({ n: e.target.value })} onBlur={() => onPatch(look.id, { n: form.n })} placeholder="01" />
          </div>
          <div className="sb-insp-field">
            <label>name</label>
            <input value={form.name} onChange={(e) => set({ name: e.target.value })} onBlur={() => onPatch(look.id, { name: form.name })} placeholder="engawa" />
          </div>
        </div>
        <div className="sb-insp-field">
          <label>body</label>
          <textarea rows={3} value={form.body} onChange={(e) => set({ body: e.target.value })} onBlur={() => onPatch(look.id, { body: form.body })} placeholder="A short editorial line about the look." />
        </div>
      </div>

      <div className="sb-insp-sec">
        <div className="sb-insp-h">photos <span className="dim">· drag to reorder · up to 2</span></div>
        {form.images.map((src, i) => (
          <div className={"sb-insp-photo" + (photoDrag.dragIdx === i ? " is-dragging" : "")} key={src + i} {...photoDrag.rowProps(i)}>
            <span className="sb-insp-grip" title="drag to reorder">⠿</span>
            <span className="sb-insp-thumb" style={{ backgroundImage: `url("${src}")` }} />
            <div className="sb-insp-photo-meta">
              <div className="sb-insp-photo-top">
                <span className="sb-insp-role">{i === 0 ? "MAIN" : "SIDE"}</span>
                <div className="sb-insp-photo-acts">
                  <label className="sb-insp-mini" title="replace">⤓
                    <input type="file" accept="image/*" hidden onChange={(e) => { if (e.target.files[0]) replacePhoto(i, e.target.files[0]); e.target.value = ""; }} />
                  </label>
                  <button title="remove" className="danger" onClick={() => removePhoto(i)}>×</button>
                </div>
              </div>
              <input className="sb-insp-meta-in" placeholder="size worn · M" value={(form.imageMeta[i] && form.imageMeta[i].size) || ""} onChange={(e) => setMeta(i, "size", e.target.value)} onBlur={commitMeta} />
              <input className="sb-insp-meta-in" placeholder="model height · 183cm" value={(form.imageMeta[i] && form.imageMeta[i].height) || ""} onChange={(e) => setMeta(i, "height", e.target.value)} onBlur={commitMeta} />
              <PhotoComposition
                skuIds={(form.imageMeta[i] && Array.isArray(form.imageMeta[i].skuIds)) ? form.imageMeta[i].skuIds : []}
                collection={collection}
                onChange={(ids) => setPhotoSkus(i, ids)} />
            </div>
          </div>
        ))}
        {form.images.length < 2 && (
          <label className="sb-insp-add">
            {uploading ? "uploading…" : "+ add photo"}
            <input type="file" accept="image/*" hidden onChange={(e) => { if (e.target.files[0]) addPhoto(e.target.files[0]); e.target.value = ""; }} />
          </label>
        )}
      </div>

      <div className="sb-insp-sec">
        <div className="sb-insp-h">layout</div>
        {(() => {
          // Height tunes the mobile-only override when editing mobile, leaving
          // desktop untouched; otherwise it tunes the shared desktop height.
          const hField = device === "mobile" ? "imgHeightMobile" : "imgHeight";
          const hVal = device === "mobile"
            ? (form.imgHeightMobile == null ? form.imgHeight : form.imgHeightMobile)
            : form.imgHeight;
          return (
            <React.Fragment>
              <div className="sb-insp-range">
                <label>section height {device === "mobile" && <span className="dim">· mobile</span>}<span className="sb-insp-range-val">{hVal}px</span></label>
                <input type="range" min={240} max={1400} step={10} value={hVal}
                  onChange={(e) => { const v = Number(e.target.value); set({ [hField]: v }); onPreview(look.id, { [hField]: v }); }}
                  onMouseUp={(e) => onPatch(look.id, { [hField]: Number(e.target.value) })}
                  onTouchEnd={(e) => onPatch(look.id, { [hField]: Number(e.target.value) })} />
              </div>
              {device === "mobile" && form.imgHeightMobile != null && (
                <button className="sb-insp-reset" onClick={() => { set({ imgHeightMobile: null }); onPatch(look.id, { imgHeightMobile: null }); }}>↺ match desktop height</button>
              )}
            </React.Fragment>
          );
        })()}
        {cols === 2 && layoutSlider("imgSplit", "balance (left width)", 15, 85, 1, "%")}
        {cols === 2 && layoutSlider("imgGap", "divider gap", 0, 80, 4, "px")}
        <div className="sb-insp-hint">also draggable directly on the page · image size &amp; position are per-device.</div>
      </div>
    </div>
  );
}

// Inspector for a chapter/opener block. Title + text, a background image, and
// layout controls (band height — per-device — text width, title size, text colour).
// Free placement of the title/text happens by dragging them on the canvas.
function ChapterInspector({ look, device, onPatch, onPreview, onUpload }) {
  const CH_DEF_H = Math.round(window.innerHeight * 0.62);
  const CH_TEXT_W = { desktop: 44, mobile: 84 };
  const bm = look.blockMeta || {};
  const [form, setForm] = React.useState(() => ({ name: look.name || "", body: look.body || "", layerName: (bm.layerName || "") }));
  React.useEffect(() => { setForm({ name: look.name || "", body: look.body || "", layerName: ((look.blockMeta || {}).layerName || "") }); }, [look.id]);
  const [uploading, setUploading] = React.useState(false);
  const set = (p) => setForm((f) => ({ ...f, ...p }));
  const img = (look.images || []).filter(Boolean)[0] || null;

  const CH_STYLE_DEF = { desktop: { titleSize: 56, textSize: 15 }, mobile: { titleSize: 34, textSize: 13 } };
  const dev = bm[device] || {};                        // values for THIS device
  const hField = device === "mobile" ? "imgHeightMobile" : "imgHeight";
  const hVal = device === "mobile"
    ? (look.imgHeightMobile == null ? (look.imgHeight == null ? CH_DEF_H : look.imgHeight) : look.imgHeightMobile)
    : (look.imgHeight == null ? CH_DEF_H : look.imgHeight);
  const textW = (dev.text || {}).w != null ? dev.text.w : CH_TEXT_W[device];
  const titleSize = dev.titleSize || bm.titleSize || CH_STYLE_DEF[device].titleSize;
  const textSize = dev.textSize || CH_STYLE_DEF[device].textSize;
  const theme = bm.theme === "dark" ? "dark" : "light";

  // setBlock = shared (theme, layer name); setDev = per-device (sizes, alignment).
  const setBlock = (patch, commit) => (commit ? onPatch : onPreview)(look.id, { blockMeta: { ...bm, ...patch } });
  const setDev = (patch, commit) => (commit ? onPatch : onPreview)(look.id, { blockMeta: { ...bm, [device]: { ...dev, ...patch } } });
  const setTextW = (v, commit) => setDev({ text: { ...(dev.text || {}), w: v } }, commit);
  const setHeight = (v, commit) => (commit ? onPatch : onPreview)(look.id, { [hField]: v });
  const replaceBg = async (file) => { setUploading(true); await onUpload(file, (path) => { const arr = (look.images || []).slice(); arr[0] = path; onPatch(look.id, { images: arr.filter(Boolean) }); }); setUploading(false); };

  return (
    <div className="sb-insp-body">
      <div className="sb-insp-sec">
        <div className="sb-insp-field">
          <label>title</label>
          <input value={form.name} onChange={(e) => set({ name: e.target.value })} onBlur={() => onPatch(look.id, { name: form.name })} placeholder="chapter one" />
        </div>
        <div className="sb-insp-field">
          <label>text</label>
          <textarea rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} onBlur={() => onPatch(look.id, { body: form.body })} placeholder="A few editorial lines." />
        </div>
      </div>

      <div className="sb-insp-sec">
        <div className="sb-insp-h">background image</div>
        {img ? (
          <div className="sb-insp-photo">
            <span className="sb-insp-thumb" style={{ backgroundImage: `url("${img}")` }} />
            <div className="sb-insp-photo-meta">
              <div className="sb-insp-photo-top">
                <span className="sb-insp-role">IMAGE</span>
                <div className="sb-insp-photo-acts">
                  <label className="sb-insp-mini" title="replace">⤓<input type="file" accept="image/*" hidden onChange={(e) => { if (e.target.files[0]) replaceBg(e.target.files[0]); e.target.value = ""; }} /></label>
                  <button title="remove" className="danger" onClick={() => onPatch(look.id, { images: [] })}>×</button>
                </div>
              </div>
              <div className="sb-insp-hint">drag on the page to pan · corners to zoom · per-device.</div>
            </div>
          </div>
        ) : (
          <label className="sb-insp-add">{uploading ? "uploading…" : "+ add background"}<input type="file" accept="image/*" hidden onChange={(e) => { if (e.target.files[0]) replaceBg(e.target.files[0]); e.target.value = ""; }} /></label>
        )}
      </div>

      <div className="sb-insp-sec">
        <div className="sb-insp-h">layout <span className="dim">· {device} only</span></div>
        <div className="sb-insp-range">
          <label>band height {device === "mobile" && <span className="dim">· mobile</span>}<span className="sb-insp-range-val">{hVal}px</span></label>
          <input type="range" min={160} max={1400} step={10} value={hVal}
            onChange={(e) => setHeight(Number(e.target.value), false)} onMouseUp={(e) => setHeight(Number(e.target.value), true)} onTouchEnd={(e) => setHeight(Number(e.target.value), true)} />
        </div>
        {device === "mobile" && look.imgHeightMobile != null && (
          <button className="sb-insp-reset" onClick={() => onPatch(look.id, { imgHeightMobile: null })}>↺ match desktop height</button>
        )}
        <div className="sb-insp-range">
          <label>text width<span className="sb-insp-range-val">{textW}%</span></label>
          <input type="range" min={20} max={100} step={2} value={textW}
            onChange={(e) => setTextW(Number(e.target.value), false)} onMouseUp={(e) => setTextW(Number(e.target.value), true)} onTouchEnd={(e) => setTextW(Number(e.target.value), true)} />
        </div>
        <div className="sb-insp-range">
          <label>title size<span className="sb-insp-range-val">{titleSize}px</span></label>
          <input type="range" min={16} max={140} step={2} value={titleSize}
            onChange={(e) => setDev({ titleSize: Number(e.target.value) }, false)} onMouseUp={(e) => setDev({ titleSize: Number(e.target.value) }, true)} onTouchEnd={(e) => setDev({ titleSize: Number(e.target.value) }, true)} />
        </div>
        <div className="sb-insp-range">
          <label>text size<span className="sb-insp-range-val">{textSize}px</span></label>
          <input type="range" min={10} max={48} step={1} value={textSize}
            onChange={(e) => setDev({ textSize: Number(e.target.value) }, false)} onMouseUp={(e) => setDev({ textSize: Number(e.target.value) }, true)} onTouchEnd={(e) => setDev({ textSize: Number(e.target.value) }, true)} />
        </div>
        <div className="sb-insp-hint">align each text from its floating toolbar on the page.</div>
      </div>

      <div className="sb-insp-sec">
        <div className="sb-insp-h">text colour <span className="dim">· both devices</span></div>
        <div className="sb-insp-theme">
          <button className={theme === "light" ? "is-on" : ""} onClick={() => setBlock({ theme: "light" }, true)}>light</button>
          <button className={theme === "dark" ? "is-on" : ""} onClick={() => setBlock({ theme: "dark" }, true)}>dark</button>
        </div>
        <div className="sb-insp-hint">drag the title and text directly on the page to place them. size, alignment &amp; position are saved per device.</div>
      </div>
    </div>
  );
}

// ── CANVAS INSPECTOR ─────────────────────────────────────────────────────────
// Properties for a kind:'canvas' block: band height + background, the element
// list (layers, top of list = front), and the selected element's controls —
// text styling (font / size / weight / colour / tracking), svg fill tint,
// element width, and a drop shadow. Selection is shared with the canvas via
// window.sbCanvasSel (click an element on the page or a row here).
function CanvasInspector({ look, device, onPatch, onPreview, onUpload }) {
  const CH_DEF_H = Math.round(window.innerHeight * 0.62);
  const bm = look.blockMeta || {};
  const els = bm.els || [];
  const [uploading, setUploading] = React.useState(false);

  // selection channel (shared with CanvasBlock)
  const [selId, setSelId] = React.useState(window.sbCanvasSel && window.sbCanvasSel.lookId === look.id ? window.sbCanvasSel.elId : null);
  React.useEffect(() => {
    const f = () => setSelId(window.sbCanvasSel && window.sbCanvasSel.lookId === look.id ? window.sbCanvasSel.elId : null);
    window.addEventListener("sb-canvas-sel", f);
    return () => window.removeEventListener("sb-canvas-sel", f);
  }, [look.id]);
  const sel = els.find((e) => e.id === selId) || null;

  const hField = device === "mobile" ? "imgHeightMobile" : "imgHeight";
  const hVal = device === "mobile"
    ? (look.imgHeightMobile == null ? (look.imgHeight == null ? CH_DEF_H : look.imgHeight) : look.imgHeightMobile)
    : (look.imgHeight == null ? CH_DEF_H : look.imgHeight);
  const setHeight = (v, commit) => (commit ? onPatch : onPreview)(look.id, { [hField]: v });

  const writeEls = (next, commit = true) => (commit ? onPatch : onPreview)(look.id, { blockMeta: { ...bm, els: next } });
  const patchSel = (patch, commit = true) => { if (sel) writeEls(els.map((e) => (e.id === sel.id ? { ...e, ...patch } : e)), commit); };
  const placeOf = (el) => el[device] || el.desktop || { x: 10, y: 10, w: 30 };
  const patchPlace = (patch, commit = true) => { if (sel) patchSel({ [device]: { ...placeOf(sel), ...patch } }, commit); };

  const newId = () => "el" + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
  const addEl = (el) => {
    const withId = { ...el, id: newId() };
    writeEls([...els, withId]);
    window.sbCanvasSelect(look.id, withId.id);
  };
  const addUpload = (type) => async (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = "";
    if (!f) return;
    setUploading(true);
    await onUpload(f, (path) => addEl(
      type === "svg"
        ? { type: "svg", src: path, fill: null, desktop: { x: 6, y: 68, w: 22 } }
        : { type: "image", src: path, desktop: { x: 8, y: 8, w: 42 } }));
    setUploading(false);
  };
  const addText = () => addEl({
    type: "text", text: "new text", font: "bulky", size: 64, weight: null,
    tracking: 0, lineHeight: 1.1, color: "#ECE8E0", align: "left", upper: false,
    desktop: { x: 10, y: 40, w: 50 },
  });
  const moveEl = (id, dir) => {
    const i = els.findIndex((e) => e.id === id);
    const j = i + dir;
    if (i < 0 || j < 0 || j >= els.length) return;
    writeEls(ledArrayMove(els, i, j));
  };
  const elLabel = (e) => e.type === "text" ? "“" + (e.text || "").slice(0, 18) + "”"
    : ((e.src || "").split("/").pop().split("?")[0].slice(0, 22) || e.type);

  // Drag layers to reorder (same mechanics as the lookbook rail). The list
  // shows front-first, so it works on the REVERSED view and un-reverses on drop.
  const [layerRows, setLayerRows] = React.useState(() => [...els].reverse());
  const layerDrag = React.useRef(null);
  const layerMoved = React.useRef(false);
  React.useEffect(() => {
    if (layerDrag.current == null) setLayerRows([...els].reverse());
  }, [els.map((e) => e.id).join(","), els]);
  const onLayerDragStart = (id) => (e) => { layerDrag.current = id; layerMoved.current = false; e.dataTransfer.effectAllowed = "move"; };
  const onLayerDragOver = (id) => (e) => {
    e.preventDefault();
    const from = layerRows.findIndex((x) => x.id === layerDrag.current);
    const to = layerRows.findIndex((x) => x.id === id);
    if (from < 0 || to < 0 || from === to) return;
    layerMoved.current = true;
    setLayerRows(ledArrayMove(layerRows, from, to));
  };
  const onLayerDragEnd = () => {
    if (layerMoved.current) writeEls([...layerRows].reverse());
    layerDrag.current = null; layerMoved.current = false;
  };

  const sh = (sel && sel.shadow) || null;
  const setShadow = (patch, commit = true) => patchSel({ shadow: { x: 6, y: 8, blur: 18, alpha: 0.35, ...(sh || {}), ...patch } }, commit);
  const range = (label, val, min, max, step, fn, fmt) => (
    <div className="sb-insp-range" key={label}>
      <label>{label}<span className="sb-insp-range-val">{fmt ? fmt(val) : val}</span></label>
      <input type="range" min={min} max={max} step={step} value={val}
        onChange={(e) => fn(Number(e.target.value), false)}
        onMouseUp={(e) => fn(Number(e.target.value), true)} onTouchEnd={(e) => fn(Number(e.target.value), true)} />
    </div>
  );
  const SWATCHES = ["#ECE8E0", "#16140f", "#ffffff", "#A72A1D"];
  const colorRow = (val, fn) => (
    <div className="sb-cv-colors">
      {SWATCHES.map((c) => (
        <button key={c} className={"sb-cv-swatch" + (val === c ? " is-on" : "")} style={{ background: c }} onClick={() => fn(c)} title={c} />
      ))}
      <input type="color" value={/^#[0-9a-f]{6}$/i.test(val || "") ? val : "#ECE8E0"} onChange={(e) => fn(e.target.value)} title="custom colour" />
    </div>
  );

  return (
    <div className="sb-insp-body">
      <div className="sb-insp-sec">
        <div className="sb-insp-h">canvas</div>
        <div className="sb-insp-range">
          <label>band height {device === "mobile" && <span className="dim">· mobile</span>}<span className="sb-insp-range-val">{hVal}px</span></label>
          <input type="range" min={160} max={1400} step={10} value={hVal}
            onChange={(e) => setHeight(Number(e.target.value), false)} onMouseUp={(e) => setHeight(Number(e.target.value), true)} onTouchEnd={(e) => setHeight(Number(e.target.value), true)} />
        </div>
        {device === "mobile" && look.imgHeightMobile != null && (
          <button className="sb-insp-reset" onClick={() => onPatch(look.id, { imgHeightMobile: null })}>↺ match desktop height</button>
        )}
        <div className="sb-insp-h" style={{ marginTop: 10 }}>background</div>
        {colorRow(bm.bg || "", (c) => onPatch(look.id, { blockMeta: { ...bm, bg: c } }))}
      </div>

      <div className="sb-insp-sec">
        <div className="sb-insp-h">elements <span className="dim">· top = front</span></div>
        <div className="sb-cv-add">
          <label className="sb-insp-add">{uploading ? "…" : "+ image"}<input type="file" accept="image/*" hidden onChange={addUpload("image")} /></label>
          <button className="sb-insp-add" onClick={addText}>+ text</button>
          <label className="sb-insp-add">{uploading ? "…" : "+ svg"}<input type="file" accept=".svg,image/svg+xml" hidden onChange={addUpload("svg")} /></label>
        </div>
        <div className="sb-cv-els">
          {layerRows.map((e) => (
            <div key={e.id} draggable
              className={"sb-cv-el-row" + (selId === e.id ? " is-on" : "") + (layerDrag.current === e.id ? " is-dragging" : "")}
              onClick={() => window.sbCanvasSelect(look.id, e.id)}
              onDragStart={onLayerDragStart(e.id)} onDragOver={onLayerDragOver(e.id)}
              onDragEnd={onLayerDragEnd} onDrop={(ev) => ev.preventDefault()}>
              <span className="sb-led-grip" title="drag to reorder">⠿</span>
              <span className="sb-cv-el-t">{e.type === "image" ? "▣" : e.type === "svg" ? "✦" : "T"}</span>
              <span className="sb-cv-el-name">{elLabel(e)}</span>
              <span className="sb-cv-el-acts">
                <button title="bring forward" onClick={(ev) => { ev.stopPropagation(); moveEl(e.id, +1); }}>↑</button>
                <button title="send back" onClick={(ev) => { ev.stopPropagation(); moveEl(e.id, -1); }}>↓</button>
                <button className="danger" title="delete element" onClick={(ev) => { ev.stopPropagation(); if (window.sbCanvasSel && window.sbCanvasSel.elId === e.id) window.sbCanvasSelect(look.id, null); writeEls(els.filter((x) => x.id !== e.id)); }}>×</button>
              </span>
            </div>
          ))}
          {els.length === 0 && <div className="sb-insp-hint">nothing yet — add an image, text or svg.</div>}
        </div>
      </div>

      {sel && (
        <div className="sb-insp-sec">
          <div className="sb-insp-h">{sel.type} <span className="dim">· drag on the page to place · {device}</span></div>
          {range("width", Math.round(placeOf(sel).w || 30), 2, 200, 1, (v, c) => patchPlace({ w: v }, c), (v) => v + "%")}

          {sel.type === "text" && (
            <React.Fragment>
              <div className="sb-insp-field">
                <label>text</label>
                <textarea rows={3} value={sel.text || ""} onChange={(e) => patchSel({ text: e.target.value }, false)} onBlur={(e) => patchSel({ text: e.target.value })} />
              </div>
              <div className="sb-insp-field">
                <label>font</label>
                <div className="sb-insp-theme">
                  {[["bulky", "bulky"], ["slender", "slender"], ["ui", "inter"], ["mono", "mono"]].map(([k, l]) => (
                    <button key={k} className={(sel.font || "ui") === k ? "is-on" : ""} onClick={() => patchSel({ font: k })}>{l}</button>
                  ))}
                </div>
              </div>
              {/* size is per-device (like chapters): mobile edits write sizeM */}
              {range("size" + (device === "mobile" ? " · mobile" : ""),
                (device === "mobile" && sel.sizeM != null ? sel.sizeM : sel.size) || 40, 8, 300, 2,
                (v, c) => patchSel(device === "mobile" ? { sizeM: v } : { size: v }, c), (v) => v + "px")}
              {device === "mobile" && sel.sizeM != null && (
                <button className="sb-insp-reset" onClick={() => patchSel({ sizeM: null })}>↺ match desktop size</button>
              )}
              {range("weight", sel.weight || (sel.font === "bulky" ? 900 : 400), 100, 900, 50, (v, c) => patchSel({ weight: v }, c))}
              {range("tracking", Math.round((sel.tracking || 0) * 100), -5, 40, 1, (v, c) => patchSel({ tracking: v / 100 }, c), (v) => (v / 100) + "em")}
              {range("line height", Math.round((sel.lineHeight || 1.2) * 10), 8, 22, 1, (v, c) => patchSel({ lineHeight: v / 10 }, c), (v) => (v / 10).toFixed(1))}
              <div className="sb-insp-field">
                <label>colour</label>
                {colorRow(sel.color || "#ECE8E0", (c) => patchSel({ color: c }))}
              </div>
              <div className="sb-insp-theme">
                {["left", "center", "right"].map((a) => (
                  <button key={a} className={(sel.align || "left") === a ? "is-on" : ""} onClick={() => patchSel({ align: a })}>{a === "left" ? "⇤" : a === "right" ? "⇥" : "↔"}</button>
                ))}
                <button className={sel.upper ? "is-on" : ""} title="uppercase" onClick={() => patchSel({ upper: !sel.upper })}>AA</button>
              </div>
            </React.Fragment>
          )}

          {sel.type === "svg" && (
            <React.Fragment>
              <div className="sb-insp-field">
                <label>fill</label>
                {colorRow(sel.fill || "", (c) => patchSel({ fill: c }))}
                {sel.fill && <button className="sb-insp-reset" onClick={() => patchSel({ fill: null })}>↺ original colours</button>}
              </div>
              <div className="sb-insp-field">
                <label>stroke</label>
                {colorRow(sel.stroke || "", (c) => patchSel({ stroke: c, strokeW: sel.strokeW == null ? 2 : sel.strokeW }))}
              </div>
              {(sel.stroke != null || sel.strokeW != null) && (
                <React.Fragment>
                  {range("thickness", sel.strokeW == null ? 2 : sel.strokeW, 0, 20, 0.5, (v, c) => patchSel({ strokeW: v }, c), (v) => v + "")}
                  <button className="sb-insp-reset" onClick={() => patchSel({ stroke: null, strokeW: null })}>× remove stroke</button>
                </React.Fragment>
              )}
            </React.Fragment>
          )}

          {sel.type === "image" && (
            <label className="sb-insp-add">{uploading ? "uploading…" : "⤓ replace image"}
              <input type="file" accept="image/*" hidden onChange={async (e) => {
                const f = e.target.files && e.target.files[0]; e.target.value = "";
                if (!f) return;
                setUploading(true);
                await onUpload(f, (path) => patchSel({ src: path }));
                setUploading(false);
              }} />
            </label>
          )}

          <div className="sb-insp-h" style={{ marginTop: 12 }}>drop shadow</div>
          {!sh ? (
            <button className="sb-insp-add" onClick={() => setShadow({})}>+ add shadow</button>
          ) : (
            <React.Fragment>
              {range("offset x", sh.x == null ? 6 : sh.x, -60, 60, 1, (v, c) => setShadow({ x: v }, c), (v) => v + "px")}
              {range("offset y", sh.y == null ? 8 : sh.y, -60, 60, 1, (v, c) => setShadow({ y: v }, c), (v) => v + "px")}
              {range("blur", sh.blur == null ? 18 : sh.blur, 0, 120, 1, (v, c) => setShadow({ blur: v }, c), (v) => v + "px")}
              {range("opacity", Math.round((sh.alpha == null ? 0.35 : sh.alpha) * 100), 0, 100, 5, (v, c) => setShadow({ alpha: v / 100 }, c), (v) => v + "%")}
              <button className="sb-insp-reset" onClick={() => patchSel({ shadow: null })}>× remove shadow</button>
            </React.Fragment>
          )}
        </div>
      )}
      {!sel && els.length > 0 && <div className="sb-insp-hint" style={{ padding: "0 14px 14px" }}>select an element on the page or in the list above to edit it.</div>}
    </div>
  );
}

// The concept pages borrow the canvas editor — they render this inspector in
// a floating panel next to their canvases (see Concepts.jsx).
window.CanvasInspector = CanvasInspector;

// ── SEQUENCE MODE ───────────────────────────────────────────────────────────
// The whole book, very zoomed out.
//   · drag a photo onto any other photo (any section) → SWAP; the origin slot
//     is marked red — that's where the OTHER photo landed (click to clear)
//   · drag a section by its LABEL → reorder the book
//   · double-click a label → rename the look; ↗ jumps to it in the editor
//   · right-click a photo → search the library and replace just that slot
//   · the POOL below = the whole media library, searchable by model / product /
//     filename; drag a pool image onto a slot to replace it (tags ride along)
function SequenceMode({ looks, collection, onPatch, onReorder, onCreate, onDelete, onClose, onJump }) {
  const [rows, setRows] = React.useState(looks);
  const drag = React.useRef(null);   // {type:'photo'|'section'|'pool'|'product', ...}
  const secMoved = React.useRef(false);
  const [dragSec, setDragSec] = React.useState(null);
  const [over, setOver] = React.useState(null);    // "lookId:idx" under the cursor
  const [marks, setMarks] = React.useState({});    // "lookId:idx" → changed-here flag
  const [media, setMedia] = React.useState(null);  // library pool
  const [poolQ, setPoolQ] = React.useState("");
  const [poolOpen, setPoolOpen] = React.useState(() => localStorage.getItem("sb-seq-pool-open") !== "0");
  const [poolTile, setPoolTile] = React.useState(() => Number(localStorage.getItem("sb-seq-pool-tile")) || 74);
  const [seqTile, setSeqTile] = React.useState(() => Number(localStorage.getItem("sb-seq-tile")) || 96);
  const sizeSeq = (n) => { setSeqTile(n); try { localStorage.setItem("sb-seq-tile", n); } catch (_) {} };
  const togglePool = () => setPoolOpen((v) => { try { localStorage.setItem("sb-seq-pool-open", v ? "0" : "1"); } catch (_) {} return !v; });
  const sizePool = (n) => { setPoolTile(n); try { localStorage.setItem("sb-seq-pool-tile", n); } catch (_) {} };
  const [renameId, setRenameId] = React.useState(null);
  const [renameVal, setRenameVal] = React.useState("");
  const [picker, setPicker] = React.useState(null); // {lookId, idx, x, y} right-click search
  const [pickQ, setPickQ] = React.useState("");
  const [hover, setHover] = React.useState(null);   // {x, y, below, skuIds, model} tag tooltip
  const [hl, setHl] = React.useState(null);         // hovered product id → highlight its photos
  const [rand, setRand] = React.useState({});       // "lookId:idx" → skuId placed by random-add (↻ re-rolls)
  const lastAdd = React.useRef(null);               // look created by click-to-add; 2nd click fills slot 2
  // click-to-add creates 1-image (hero) or 2-image sections
  const [addMode, setAddMode] = React.useState(() => Number(localStorage.getItem("sb-seq-addmode")) || 2);
  const pickAddMode = (n) => { setAddMode(n); try { localStorage.setItem("sb-seq-addmode", n); } catch (_) {} };

  React.useEffect(() => {
    // drop local-mutation overrides once the prop reflects them
    for (const l of looks) {
      const o = localMut.current[l.id];
      if (o && JSON.stringify(l.images) === JSON.stringify(o.images)
            && JSON.stringify(l.imageMeta) === JSON.stringify(o.imageMeta)) delete localMut.current[l.id];
    }
    if (!drag.current || drag.current.type !== "section") setRows(looks);
  }, [looks]);
  React.useEffect(() => {
    window.sbAdmin.get("/api/admin/media").then(setMedia).catch(() => setMedia([]));
  }, []);
  React.useEffect(() => {
    const esc = (e) => { if (e.key === "Escape") { if (picker) setPicker(null); else onClose(); } };
    window.addEventListener("keydown", esc);
    return () => window.removeEventListener("keydown", esc);
  }, [onClose, picker]);
  React.useEffect(() => {
    if (!picker) return;
    const close = () => setPicker(null);
    window.addEventListener("click", close);
    return () => window.removeEventListener("click", close);
  }, [picker]);

  // Mutations must read the LIVE state, not `rows` (one render behind) and not
  // even the prop (React may not have re-rendered between two quick drops).
  // localMut records each mutation's images/meta immediately; liveById merges
  // it over the prop, and the sync effect clears entries once the prop catches
  // up — so back-to-back swaps never compute from pre-swap arrays.
  const looksRef = React.useRef(looks);
  looksRef.current = looks;
  const localMut = React.useRef({});   // lookId → {images, imageMeta}
  const liveById = () => {
    const m = {};
    for (const l of looksRef.current) {
      const o = localMut.current[l.id];
      m[l.id] = o ? { ...l, ...o } : l;
    }
    return m;
  };
  const recordMut = (lookId, images, imageMeta) => { localMut.current[lookId] = { images, imageMeta }; };
  const byId = {}; for (const l of rows) byId[l.id] = l;
  // search matches filename, MODEL, and names/colours of the tagged products
  const matches = (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);
  };

  const doSwap = (a, b) => {
    if (!a || !b || (a.lookId === b.lookId && a.idx === b.idx)) return;
    const live = liveById();
    const A = live[a.lookId], B = live[b.lookId];
    if (!A || !B || !(A.images || [])[a.idx] || !(B.images || [])[b.idx]) return;
    const same = A === B;
    const imgsA = (A.images || []).slice(), imgsB = same ? imgsA : (B.images || []).slice();
    const metaA = (A.imageMeta || []).slice(), metaB = same ? metaA : (B.imageMeta || []).slice();
    const ti = imgsA[a.idx]; imgsA[a.idx] = imgsB[b.idx]; imgsB[b.idx] = ti;
    const tm = metaA[a.idx] || {}; metaA[a.idx] = metaB[b.idx] || {}; metaB[b.idx] = tm;
    const cleanA = metaA.map((m) => m || {}), cleanB = metaB.map((m) => m || {});
    recordMut(A.id, imgsA, cleanA);
    onPatch(A.id, { images: imgsA, imageMeta: cleanA });
    if (!same) { recordMut(B.id, imgsB, cleanB); onPatch(B.id, { images: imgsB, imageMeta: cleanB }); }
    setMarks((ms) => ({ ...ms, [a.lookId + ":" + a.idx]: true }));   // red = origin slot
  };
  // Replace one slot with a library image — the image's tags/model ride along.
  const replaceSlot = (lookId, idx, m) => {
    const L = liveById()[lookId]; if (!L || !m) return;
    const imgs = (L.images || []).slice();
    while (imgs.length <= idx) imgs.push(null);
    imgs[idx] = m.path;
    const meta = (L.imageMeta || []).map((o) => ({ ...(o || {}) }));
    while (meta.length <= idx) meta.push({});
    meta[idx] = {
      ...meta[idx],
      skuIds: (m.skuIds || []).slice(),
      model: m.model || "",
      modelHeight: m.modelHeight != null ? String(m.modelHeight) : "",
      height: m.modelHeight ? m.modelHeight + "cm" : (meta[idx].height || ""),
    };
    const cleanImgs = imgs.filter(Boolean);
    recordMut(lookId, cleanImgs, meta);
    onPatch(lookId, { images: cleanImgs, imageMeta: meta });
    setMarks((ms) => ({ ...ms, [lookId + ":" + idx]: true }));
    setPicker(null);
  };

  // section reorder — the LABEL is the drag handle; live-preview like the rail
  const onSecStart = (id) => (e) => {
    drag.current = { type: "section", id }; secMoved.current = false; setDragSec(id);
    e.dataTransfer.effectAllowed = "move";
    try { e.dataTransfer.setData("text/plain", "sec:" + id); } catch (_) {}
  };
  const onBlockOver = (id, acceptProduct) => (e) => {
    const d = drag.current;
    if (!d) return;
    if (d.type === "product") {   // let a product drop land on the card (append)
      if (acceptProduct) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }
      return;
    }
    if (d.type !== "section") return;
    e.preventDefault();
    const from = rows.findIndex((x) => x.id === d.id), to = rows.findIndex((x) => x.id === id);
    if (from < 0 || to < 0 || from === to) return;
    secMoved.current = true;
    setRows(ledArrayMove(rows, from, to));
  };
  // Product dropped on a section card (not a specific photo) → append a random
  // unused image of it as the section's next photo (max 2).
  const onCardDrop = (l) => (e) => {
    const d = drag.current;
    if (!d || d.type !== "product") return;
    e.preventDefault(); drag.current = null; setOver(null);
    const m = randomOf(d.skuId);
    if (m) appendImage(liveById()[l.id] || l, m, d.skuId);
  };
  const onSecEnd = () => {
    if (drag.current && drag.current.type === "section" && secMoved.current) onReorder(rows.map((r) => r.id));
    drag.current = null; secMoved.current = false; setDragSec(null);
  };

  const commitRename = (l) => { onPatch(l.id, { name: renameVal.trim() }); setRenameId(null); };

  // ── product checklist helpers ────────────────────────────────────────────
  // How often each product appears across the sequence photos (kind:'look').
  const skuCounts = {};
  for (const l of rows) {
    if (l.kind === "chapter") continue;
    for (const m of (l.imageMeta || [])) for (const id of (m && m.skuIds) || []) skuCounts[id] = (skuCounts[id] || 0) + 1;
  }
  // Random library image of a product, preferring ones NOT already in the book.
  const usedPaths = new Set();
  for (const l of rows) for (const p of (l.images || [])) if (p) usedPaths.add(String(p).split("?")[0]);
  const randomOf = (skuId, excludePath) => {
    const all = (media || []).filter((m) => (m.skuIds || []).includes(skuId) && m.path !== excludePath);
    const unused = all.filter((m) => !usedPaths.has(m.path));
    const pool = unused.length ? unused : all;
    return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null;
  };
  const metaFor = (m) => ({
    size: "", height: m.modelHeight ? m.modelHeight + "cm" : "",
    desktop: { s: 1, x: 50, y: 35 }, mobile: { s: 1, x: 50, y: 35 },
    skuIds: (m.skuIds || []).slice(), model: m.model || "",
    modelHeight: m.modelHeight != null ? String(m.modelHeight) : "",
  });
  // Append a media image as a look's next photo (product drop on a card / click-add).
  const appendImage = (l, m, skuId) => {
    const idx = (l.images || []).length;
    if (idx >= 2) return false;
    const imgs = [...(l.images || []), m.path];
    const meta = [...(l.imageMeta || []).map((o) => ({ ...(o || {}) })), metaFor(m)];
    recordMut(l.id, imgs, meta);
    onPatch(l.id, { images: imgs, imageMeta: meta });
    setRand((rs) => ({ ...rs, [l.id + ":" + idx]: skuId }));
    return true;
  };
  // Click a product: random unused image → NEW 2-image section; the next click
  // fills that section's second slot, then the one after starts a new section.
  const clickAdd = async (skuId) => {
    const last = lastAdd.current != null ? liveById()[lastAdd.current] : null;
    const m = randomOf(skuId);
    if (!m) return;
    // pair mode: the previous click-created section still has an empty 2nd slot
    if (addMode === 2 && last && (last.images || []).length === 1) { appendImage(last, m, skuId); return; }
    const nextN = String(rows.filter((l) => l.kind !== "chapter").reduce((mx, l) => Math.max(mx, parseInt(l.n, 10) || 0), 0) + 1);
    const u = await onCreate({
      n: nextN, name: "new look", kind: "look", imgCols: addMode === 1 ? 1 : 2,
      images: [m.path], imageMeta: [metaFor(m)], sortOrder: rows.length,
    });
    if (u) {
      lastAdd.current = addMode === 2 ? u.id : null;   // heroes are complete at once
      setRand((rs) => ({ ...rs, [u.id + ":0"]: skuId }));
    }
  };
  // ↻ on a random-added photo — another image of the same item instead.
  // remove one image from a section (right-click → remove image)
  const removeSlot = (lookId, idx) => {
    const L = liveById()[lookId]; if (!L) return;
    const imgs = (L.images || []).filter((_, k) => k !== idx);
    const meta = (L.imageMeta || []).map((o) => ({ ...(o || {}) })).filter((_, k) => k !== idx);
    recordMut(lookId, imgs, meta);
    onPatch(lookId, { images: imgs, imageMeta: meta });
    // slot-keyed decorations are misaligned after a removal — clear this look's
    const clear = (o) => { const n = { ...o }; for (const k of Object.keys(n)) if (k.startsWith(lookId + ":")) delete n[k]; return n; };
    setMarks(clear); setRand(clear);
    setPicker(null);
  };
  const reroll = (lookId, idx) => {
    const skuId = rand[lookId + ":" + idx];
    const L = liveById()[lookId];
    if (!skuId || !L) return;
    const m = randomOf(skuId, ((L.images || [])[idx] || "").split("?")[0]);
    if (m) replaceSlot(lookId, idx, m);
  };

  // hover tooltip — thumbnails of the products tagged in the hovered image
  const showTip = (e, skuIds, model) => {
    const r = e.currentTarget.getBoundingClientRect();
    const below = r.top < 170;
    setHover({
      x: Math.max(8, Math.min(r.left, window.innerWidth - 250)),
      y: below ? r.bottom + 6 : r.top - 6, below,
      skuIds: skuIds || [], model: model || "",
    });
  };

  const poolItems = media ? media.filter((m) => matches(m, poolQ)) : null;
  const pickItems = media ? media.filter((m) => matches(m, pickQ)).slice(0, 60) : null;

  const photoTile = (l, src, i) => {
    const key = l.id + ":" + i;
    const metaSkus = ((l.imageMeta || [])[i] || {}).skuIds || [];
    return (
      <div key={i}
        className={"sb-seq-ph" + (over === key ? " is-over" : "") + (marks[key] ? " is-swapped" : "")
          + (hl && metaSkus.includes(hl) ? " is-hl" : "")}
        draggable
        onMouseEnter={(e) => { const m = (l.imageMeta || [])[i] || {}; showTip(e, m.skuIds, m.model); }}
        onMouseLeave={() => setHover(null)}
        onDragStart={(e) => {
          setHover(null);
          drag.current = { type: "photo", lookId: l.id, idx: i };
          e.dataTransfer.effectAllowed = "move";
          try { e.dataTransfer.setData("text/plain", key); } catch (_) {}
        }}
        onDragOver={(e) => {
          const d = drag.current;
          if (!d || d.type === "section") return;
          e.preventDefault();
          // dropEffect must match the source's effectAllowed or the browser
          // cancels the drop (pool/product drag as "copy", photos as "move")
          e.dataTransfer.dropEffect = d.type === "photo" ? "move" : "copy";
          setOver(key);
        }}
        onDragLeave={() => setOver((o) => (o === key ? null : o))}
        onDrop={(e) => {
          const d = drag.current; drag.current = null; setOver(null);
          if (!d || d.type === "section") return;
          e.preventDefault(); e.stopPropagation();
          if (d.type === "photo") doSwap(d, { lookId: l.id, idx: i });
          else if (d.type === "pool") replaceSlot(l.id, i, d.media);
          else if (d.type === "product") {   // random unused image of the item into THIS slot
            const m = randomOf(d.skuId, String(src).split("?")[0]);
            if (m) { replaceSlot(l.id, i, m); setRand((rs) => ({ ...rs, [key]: d.skuId })); }
          }
        }}
        onDragEnd={() => { setOver(null); drag.current = null; }}
        onClick={() => marks[key] && setMarks((ms) => { const n = { ...ms }; delete n[key]; return n; })}
        onContextMenu={(e) => {
          e.preventDefault(); e.stopPropagation(); setPickQ("");
          setPicker({ lookId: l.id, idx: i, x: Math.min(e.clientX, window.innerWidth - 400), y: Math.min(e.clientY, window.innerHeight - 480) });
        }}
        title={marks[key] ? "changed — click to clear the mark" : "drag to swap · right-click to search a different image"}>
        <img src={src} alt="" draggable={false} loading="lazy" />
        {marks[key] && <span className="sb-seq-flag">SWAPPED IN</span>}
        {rand[key] && (
          <button className="sb-seq-rand" title="↻ another random image of this item"
            onClick={(e) => { e.stopPropagation(); reroll(l.id, i); }}>↻</button>
        )}
      </div>
    );
  };

  return (
    <div className={"sb-seq" + (hl ? " has-hl" : "")}>
      <div className="sb-seq-head">
        <span className="sb-seq-title">SEQUENCE</span>
        <span className="sb-seq-hint">
          drag photos to swap · drag a label to move the section · double-click a label to rename ·
          right-click a photo to search · <span className="sb-seq-hint-red">red</span> = changed (click to clear)
        </span>
        <label className="sb-seq-hint" title="photo size on the board" style={{ display: "flex", alignItems: "center", gap: 8 }}>
          SIZE
          <input type="range" min="64" max="200" step="8" value={seqTile} style={{ width: 110 }}
            onChange={(e) => sizeSeq(Number(e.target.value))} />
        </label>
        {Object.keys(marks).length > 0 && (
          <button className="sb-seq-btn" onClick={() => setMarks({})}>clear marks</button>
        )}
        <button className="sb-seq-btn sb-seq-done" onClick={onClose}>[ done ]</button>
      </div>

      <div className="sb-seq-mid">
      <div className="sb-seq-body" style={{ "--seq-ph": seqTile + "px" }}>
        {rows.map((l) => l.kind === "chapter" ? (
          <div key={l.id} className="sb-seq-chapter" onDragOver={onBlockOver(l.id)}>
            ¶ {(l.blockMeta && l.blockMeta.layerName) || l.name || "chapter"}
          </div>
        ) : (
          <div key={l.id}
            className={"sb-seq-look" + (l.hidden ? " is-hidden" : "") + (dragSec === l.id ? " is-secdrag" : "")}
            onDragOver={onBlockOver(l.id, (l.images || []).length < 2)} onDrop={onCardDrop(l)}>
            <div className="sb-seq-label" draggable={renameId !== l.id}
              onDragStart={onSecStart(l.id)} onDragEnd={onSecEnd} onDrop={(e) => e.preventDefault()}>
              <span className="sb-seq-grip" title="drag to move this section">⠿</span>
              <span className="sb-seq-n">{l.n || "·"}</span>
              {renameId === l.id ? (
                <input className="sb-seq-rename" autoFocus value={renameVal}
                  onChange={(e) => setRenameVal(e.target.value)} onBlur={() => commitRename(l)}
                  onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
                  onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); else if (e.key === "Escape") setRenameId(null); }} />
              ) : (
                <span className="sb-seq-name" title="double-click to rename"
                  onDoubleClick={(e) => { e.stopPropagation(); setRenameId(l.id); setRenameVal(l.name || ""); }}>
                  {l.name || "look"}{l.hidden ? " · hidden" : ""}
                </span>
              )}
              <button className="sb-seq-jump" title="open this look in the editor"
                onClick={(e) => { e.stopPropagation(); onJump(l.id); onClose(); }}>↗</button>
              <button className="sb-seq-remove" title="remove this section from the sequence"
                onClick={(e) => {
                  e.stopPropagation();
                  if (confirm(`Remove look ${l.n || ""} · ${l.name || "look"} from the sequence?`)) onDelete(l.id);
                }}>×</button>
            </div>
            <div className="sb-seq-imgs">
              {(l.images || []).map((src, i) => photoTile(l, src, i))}
              {(l.images || []).length === 0 && (
                <div className={"sb-seq-empty" + (over === l.id + ":0" ? " is-over" : "")}
                  onDragOver={(e) => {
                    const d = drag.current;
                    if (!d || (d.type !== "pool" && d.type !== "product")) return;
                    e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setOver(l.id + ":0");
                  }}
                  onDragLeave={() => setOver((o) => (o === l.id + ":0" ? null : o))}
                  onDrop={(e) => {
                    const d = drag.current; drag.current = null; setOver(null);
                    if (!d || (d.type !== "pool" && d.type !== "product")) return;
                    e.preventDefault(); e.stopPropagation();
                    if (d.type === "pool") replaceSlot(l.id, 0, d.media);
                    else { const m = randomOf(d.skuId); if (m) { replaceSlot(l.id, 0, m); setRand((rs) => ({ ...rs, [l.id + ":0"]: d.skuId })); } }
                  }}>drop an image here</div>
              )}
            </div>
          </div>
        ))}
      </div>

      {/* product checklist — every item, its count in the book; hover to see
          where it appears; drag onto a section/photo or click to add a random
          image of it that isn't in the sequence yet */}
      <aside className="sb-seq-prods">
        <div className="sb-seq-prods-head">
          <span className="sb-seq-title">ITEMS</span>
          <span className="sb-seq-addmode" title="click-to-add creates a 1-image (hero) or 2-image section">
            <button className={addMode === 1 ? "is-on" : ""} onClick={() => pickAddMode(1)}>1</button>
            <button className={addMode === 2 ? "is-on" : ""} onClick={() => pickAddMode(2)}>2</button>
          </span>
          <span className="sb-seq-hint">hover = where · click = add random ({addMode === 1 ? "hero" : "pair"}) · drag = place</span>
        </div>
        <div className="sb-seq-prods-list">
          {(collection || []).map((s) => {
            const count = skuCounts[s.id] || 0;
            return (
              <div key={s.id}
                className={"sb-seq-prod" + (count ? "" : " is-missing") + (hl === s.id ? " is-hot" : "")}
                draggable
                onMouseEnter={() => setHl(s.id)} onMouseLeave={() => setHl(null)}
                onDragStart={(e) => {
                  setHover(null);
                  drag.current = { type: "product", skuId: s.id };
                  e.dataTransfer.effectAllowed = "copy";
                  try { e.dataTransfer.setData("text/plain", "sku:" + s.id); } catch (_) {}
                }}
                onDragEnd={() => { drag.current = null; setOver(null); }}
                onClick={() => clickAdd(s.id)}
                title="click: add a random unused image to a new section · drag onto a section or photo to place it">
                <span className="sb-seq-prod-thumb" style={{ background: s.bg, backgroundSize: "contain" }} />
                <span className="sb-seq-prod-name">
                  {s.name.toLowerCase()}
                  {s.colorway && s.colorway.name ? <em> · {s.colorway.name.toLowerCase()}</em> : null}
                </span>
                <span className="sb-seq-prod-count">{count ? "×" + count : "—"}</span>
                <span className={"sb-seq-prod-tick" + (count ? " is-on" : "")}>{count ? "✓" : "○"}</span>
              </div>
            );
          })}
        </div>
      </aside>
      </div>{/* /sb-seq-mid */}

      {/* the POOL — whole media library; drag a tile onto any slot to replace it.
          Collapsible + tile size is adjustable (both remembered). */}
      <div className={"sb-seq-pool" + (poolOpen ? "" : " is-collapsed")}>
        <div className="sb-seq-pool-head">
          <button className="sb-seq-pool-fold" onClick={togglePool}
            title={poolOpen ? "collapse the pool" : "expand the pool"}>
            {poolOpen ? "▾" : "▸"} POOL
          </button>
          {poolOpen && (
            <React.Fragment>
              <input className="sb-seq-pool-q" placeholder="search model / product / filename…"
                value={poolQ} onChange={(e) => setPoolQ(e.target.value)} onKeyDown={(e) => e.stopPropagation()} />
              <label className="sb-seq-hint" title="tile size" style={{ display: "flex", alignItems: "center", gap: 8 }}>
                SIZE
                <input type="range" min="48" max="150" step="6" value={poolTile} style={{ width: 110 }}
                  onChange={(e) => sizePool(Number(e.target.value))} />
              </label>
            </React.Fragment>
          )}
          <span className="sb-seq-hint">
            {poolItems ? poolItems.length + " image" + (poolItems.length === 1 ? "" : "s") : "loading…"}{poolOpen ? " · drag onto a photo to replace it" : ""}
          </span>
        </div>
        {poolOpen && (
        <div className="sb-seq-pool-grid">
          {(poolItems || []).map((m) => (
            <div key={m.id} className="sb-seq-pool-ph" draggable
              style={{ width: poolTile }}
              title={(m.name || "") + (m.model ? " · " + m.model : "")}
              onMouseEnter={(e) => showTip(e, m.skuIds, m.model)}
              onMouseLeave={() => setHover(null)}
              onDragStart={(e) => {
                setHover(null);
                drag.current = { type: "pool", media: m };
                e.dataTransfer.effectAllowed = "copy";
                try { e.dataTransfer.setData("text/plain", "pool:" + m.id); } catch (_) {}
              }}
              onDragEnd={() => { drag.current = null; setOver(null); }}>
              <img src={m.path + (m.ver ? "?v=" + m.ver : "")} alt="" draggable={false} loading="lazy" />
              {m.model && <span className="sb-seq-pool-cap">{m.model.toUpperCase()}</span>}
            </div>
          ))}
          {poolItems && poolItems.length === 0 && <div className="sb-seq-hint" style={{ padding: 12 }}>no match.</div>}
        </div>
        )}
      </div>

      {/* hover — what's tagged in this image */}
      {hover && (
        <div className="sb-seq-tip" style={{ left: hover.x, top: hover.y, transform: hover.below ? "none" : "translateY(-100%)" }}>
          {hover.model && <div className="sb-seq-tip-model">{hover.model.toUpperCase()}</div>}
          {hover.skuIds.length ? hover.skuIds.map((id) => {
            const p = (collection || []).find((s) => s.id === id);
            return (
              <div key={id} className="sb-seq-tip-item">
                <span className="sb-seq-tip-thumb" style={p ? { background: p.bg, backgroundSize: "contain" } : null} />
                <span className="sb-seq-tip-name">
                  {p ? p.name.toLowerCase() : id}
                  {p && p.colorway && p.colorway.name ? <em> · {p.colorway.name.toLowerCase()}</em> : null}
                </span>
              </div>
            );
          }) : <div className="sb-seq-tip-none">no items tagged</div>}
        </div>
      )}

      {/* right-click search — replace just this slot */}
      {picker && (
        <div className="sb-seq-picker" style={{ left: picker.x, top: picker.y }} onClick={(e) => e.stopPropagation()}>
          <div className="sb-seq-picker-head">
            <span>REPLACE IMAGE</span>
            <button className="sb-seq-removeimg" title="remove this image from the section"
              onClick={() => removeSlot(picker.lookId, picker.idx)}>remove image</button>
            <button className="sb-seq-copyname" title="copy this image's ORIGINAL file name (as uploaded)"
              onClick={() => {
                const L = liveById()[picker.lookId];
                const p = L && (L.images || [])[picker.idx];
                if (p && navigator.clipboard) {
                  const clean = String(p).split("?")[0];
                  const row = (media || []).find((x) => x.path === clean);
                  navigator.clipboard.writeText((row && row.name) || clean.split("/").pop()).catch(() => {});
                }
                setPicker(null);
              }}>⧉ copy file name</button>
            <button onClick={() => setPicker(null)}>×</button>
          </div>
          <input className="sb-seq-pool-q" autoFocus placeholder="search model / product / filename…"
            value={pickQ} onChange={(e) => setPickQ(e.target.value)} onKeyDown={(e) => e.stopPropagation()} />
          <div className="sb-seq-picker-grid">
            {(pickItems || []).map((m) => (
              <button key={m.id} className="sb-seq-picker-ph" title={(m.name || "") + (m.model ? " · " + m.model : "")}
                onClick={() => replaceSlot(picker.lookId, picker.idx, m)}>
                <img src={m.path + (m.ver ? "?v=" + m.ver : "")} alt="" loading="lazy" />
              </button>
            ))}
            {pickItems && pickItems.length === 0 && <div className="sb-seq-hint" style={{ padding: 10 }}>no match.</div>}
          </div>
        </div>
      )}
    </div>
  );
}

function LookbookEditor({ looks, collection, selectedId, onSelect, dirty, status, device,
                          onCreate, onCreateChapter, onCreateCanvas, onDelete, onReorder, onDuplicate, onPublish, onDiscard,
                          onLoadVersion, onUndo, undoDepth, onPatch, onPreview, onUpload }) {
  const [rows, setRows] = React.useState(looks);
  const dragId = React.useRef(null);
  const moved = React.useRef(false);
  const [dragging, setDragging] = React.useState(null);
  React.useEffect(() => { if (dragId.current == null) setRows(looks); }, [looks]);

  const onDragStart = (id) => (e) => { dragId.current = id; moved.current = false; setDragging(id); e.dataTransfer.effectAllowed = "move"; };
  const onDragOver = (id) => (e) => {
    e.preventDefault();
    const from = rows.findIndex((x) => x.id === dragId.current);
    const to = rows.findIndex((x) => x.id === id);
    if (from < 0 || to < 0 || from === to) return;
    moved.current = true; setRows(ledArrayMove(rows, from, to));
  };
  const onDragEnd = () => {
    if (moved.current) onReorder(rows.map((r) => r.id));
    dragId.current = null; moved.current = false; setDragging(null);
  };

  // Clicking a rail row selects the block AND scrolls it into view, so the canvas
  // follows the rail (and the scroll-watcher then agrees on the selection).
  const selectAndScroll = (id) => {
    onSelect(id);
    const el = document.querySelector('.lb-look[data-look-id="' + id + '"], .lb-chapter[data-look-id="' + id + '"], .lb-canvas[data-look-id="' + id + '"]');
    if (el) el.scrollIntoView({ block: "center" });
  };

  // Inline rename from the rail (double-click). Chapters → layer name; looks → name.
  const [renameId, setRenameId] = React.useState(null);
  const [renameVal, setRenameVal] = React.useState("");
  const railLabel = (l) => (l.kind === "chapter" || l.kind === "canvas")
    ? ((l.blockMeta && l.blockMeta.layerName) || l.name || l.kind) : (l.name || "—");
  const beginRename = (l) => { setRenameId(l.id); setRenameVal(l.kind === "chapter" ? ((l.blockMeta && l.blockMeta.layerName) || l.name || "") : (l.name || "")); };
  const commitRename = (l) => {
    const v = renameVal.trim();
    if (l.kind === "chapter") onPatch(l.id, { blockMeta: { ...(l.blockMeta || {}), layerName: v } });
    else onPatch(l.id, { name: v });
    setRenameId(null);
  };

  const selected = looks.find((l) => l.id === selectedId) || null;

  // ── VERSIONS — named snapshots of the draft book ──────────────────────────
  const [verOpen, setVerOpen] = React.useState(false);
  const [versions, setVersions] = React.useState(null);   // null = not loaded
  const [verName, setVerName] = React.useState("");
  const [verBusy, setVerBusy] = React.useState(false);
  const [verErr, setVerErr] = React.useState("");
  const [verRenameId, setVerRenameId] = React.useState(null);
  const [verRenameVal, setVerRenameVal] = React.useState("");
  const loadVersions = () => window.sbAdmin.get("/api/admin/looks/versions").then(setVersions).catch((e) => setVerErr(e.message));
  React.useEffect(() => { if (verOpen && versions === null) loadVersions(); }, [verOpen]);
  const verAct = async (fn) => {
    setVerBusy(true); setVerErr("");
    try { await fn(); await loadVersions(); } catch (e) { setVerErr(e.message); }
    setVerBusy(false);
  };
  const saveVersion = () => {
    const name = verName.trim() || ("version · " + new Date().toLocaleDateString());
    verAct(async () => { await window.sbAdmin.post("/api/admin/looks/versions", { name }); setVerName(""); });
  };
  const switchVersion = (v) => {
    if (!confirm(`Switch the draft to “${v.name}”?\n\nThe CURRENT arrangement will be replaced — save it as a version first if you want to keep it. (Retailers keep seeing the published book until you publish.)`)) return;
    verAct(async () => { await onLoadVersion(v.id); });
  };

  return (
    <React.Fragment>
      <aside className="sb-led-rail">
        <div className="sb-led-rail-head">
          <span>LOOKBOOK</span>
          <span className="sb-led-head-r">
            <button className="sb-led-undo" disabled={!undoDepth} onClick={onUndo} title="undo (⌘Z)">↶</button>
            <span className="dim">{rows.length} looks</span>
          </span>
        </div>
        <div className="sb-led-list">
          {rows.map((l) => (
            <div key={l.id} draggable={renameId !== l.id}
              className={"sb-led-look" + (l.kind === "chapter" ? " is-chapter" : "") + (l.id === selectedId ? " is-active" : "") + (l.hidden ? " is-hidden" : "") + (dragging === l.id ? " is-dragging" : "")}
              onClick={() => selectAndScroll(l.id)}
              onDragStart={onDragStart(l.id)} onDragOver={onDragOver(l.id)} onDragEnd={onDragEnd} onDrop={(e) => e.preventDefault()}>
              <span className="sb-led-grip" title="drag to reorder">⠿</span>
              <span className={"sb-led-n" + (l.kind === "chapter" || l.kind === "canvas" ? " sb-led-n-ch" : "")}>{l.kind === "chapter" ? "¶" : l.kind === "canvas" ? "✦" : l.n}</span>
              {renameId === l.id ? (
                <input className="sb-led-rename" autoFocus value={renameVal}
                  onChange={(e) => setRenameVal(e.target.value)} onBlur={() => commitRename(l)}
                  onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
                  onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); else if (e.key === "Escape") setRenameId(null); }} />
              ) : (
                <span className="sb-led-name" title="double-click to rename" onDoubleClick={(e) => { e.stopPropagation(); beginRename(l); }}>{railLabel(l)}</span>
              )}
              <button className="sb-led-eye" title={l.hidden ? "show on storefront" : "hide from storefront"}
                onClick={(e) => { e.stopPropagation(); onPatch(l.id, { hidden: !l.hidden }); }}>{l.hidden ? "𝅄" : "◉"}</button>
              <button className="sb-led-del danger" title="delete look"
                onClick={(e) => { e.stopPropagation(); if (confirm(`Delete look ${l.n} · ${l.name}?`)) onDelete(l.id); }}>×</button>
            </div>
          ))}
        </div>
        <div className="sb-led-add-row">
          <button className="sb-led-add" onClick={onCreate}>+ look</button>
          <button className="sb-led-add" onClick={onCreateChapter}>+ chapter</button>
          <button className="sb-led-add" onClick={onCreateCanvas}>+ canvas</button>
        </div>
        <div className="sb-led-publish">
          <div className="sb-led-pub-status">
            <span className={"sb-led-dot" + (dirty ? " is-dirty" : "")} />
            {dirty ? "unpublished changes" : "published · live"}
          </div>
          <div className="sb-led-pub-btns">
            <button className="sb-led-pub-btn primary" disabled={!dirty} onClick={onPublish}>publish</button>
            <button className="sb-led-pub-btn" disabled={!dirty} onClick={() => { if (confirm("Discard all unpublished lookbook changes?")) onDiscard(); }}>discard</button>
          </div>
          <button className={"sb-led-pub-btn sb-led-versions-btn" + (verOpen ? " is-on" : "")}
            onClick={() => setVerOpen((v) => !v)} title="named snapshots of the book — save, switch, copy">
            ⧉ versions{versions && versions.length ? " · " + versions.length : ""}
          </button>

          {verOpen && (
            <div className="sb-led-versions" onClick={(e) => e.stopPropagation()}>
              <div className="sb-led-ver-save">
                <input className="sb-led-ver-name" placeholder="name this version…" value={verName}
                  onChange={(e) => setVerName(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Enter") saveVersion(); }} />
                <button className="sb-led-pub-btn primary" disabled={verBusy} onClick={saveVersion}>save current</button>
              </div>
              {verErr && <div className="sb-led-ver-err">{verErr}</div>}
              <div className="sb-led-ver-list">
                {versions === null && <div className="sb-led-ver-empty">loading…</div>}
                {versions && versions.length === 0 && <div className="sb-led-ver-empty">no versions yet — save the current book above.</div>}
                {(versions || []).map((v) => (
                  <div key={v.id} className="sb-led-ver">
                    {verRenameId === v.id ? (
                      <input className="sb-led-rename" autoFocus value={verRenameVal}
                        onChange={(e) => setVerRenameVal(e.target.value)}
                        onBlur={() => { const n = verRenameVal.trim(); setVerRenameId(null); if (n && n !== v.name) verAct(() => window.sbAdmin.patch("/api/admin/looks/versions/" + v.id, { name: n })); }}
                        onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); else if (e.key === "Escape") setVerRenameId(null); }} />
                    ) : (
                      <span className="sb-led-ver-name-l" title="double-click to rename"
                        onDoubleClick={() => { setVerRenameId(v.id); setVerRenameVal(v.name); }}>
                        {v.name}
                        <span className="dim"> · {v.blocks} blocks · {new Date(v.updatedAt).toLocaleDateString()}</span>
                      </span>
                    )}
                    <span className="sb-led-ver-acts">
                      <button disabled={verBusy} title="switch the draft to this version" onClick={() => switchVersion(v)}>open</button>
                      <button disabled={verBusy} title="duplicate this version" onClick={() => verAct(() => window.sbAdmin.post("/api/admin/looks/versions/" + v.id + "/copy", {}))}>⧉</button>
                      <button disabled={verBusy} className="danger" title="delete this version"
                        onClick={() => { if (confirm(`Delete version “${v.name}”? (The draft book is not affected.)`)) verAct(() => window.sbAdmin.del("/api/admin/looks/versions/" + v.id)); }}>×</button>
                    </span>
                  </div>
                ))}
              </div>
            </div>
          )}
        </div>
      </aside>

      {selected && (
        <aside className="sb-insp">
          <div className="sb-insp-head">
            <span>{selected.kind === "chapter" ? "CHAPTER" : selected.kind === "canvas" ? "CANVAS" : "LOOK · " + selected.n}</span>
            <button className="sb-insp-close" onClick={() => onSelect(null)} title="close">×</button>
          </div>
          {selected.kind === "chapter"
            ? <ChapterInspector key={selected.id} look={selected} device={device}
                onPatch={onPatch} onPreview={onPreview} onUpload={onUpload} />
            : selected.kind === "canvas"
            ? <CanvasInspector key={selected.id} look={selected} device={device}
                onPatch={onPatch} onPreview={onPreview} onUpload={onUpload} />
            : <LookInspector key={selected.id} look={selected} collection={collection} device={device}
                onPatch={onPatch} onPreview={onPreview} onUpload={onUpload} />}
        </aside>
      )}
    </React.Fragment>
  );
}

window.LookbookEditor = LookbookEditor;
// Sequence mode mounts from App as its OWN page (the heavy lookbook canvas is
// unmounted underneath it), not as an overlay over the editor.
window.SBSequenceMode = SequenceMode;
